Simplify grid focus, handle items w/ missing images

This commit is contained in:
Damontecres 2025-10-08 15:49:31 -04:00
parent 2212e32c53
commit b63e933386
No known key found for this signature in database
13 changed files with 352 additions and 195 deletions

View file

@ -103,6 +103,7 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",

View file

@ -90,6 +90,9 @@ data class BaseItem(
} else { } else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
} }
} else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) {
// TODO is this a bad assumption?
null
} else { } else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
} }

View file

@ -18,7 +18,7 @@ sealed class AppColors private constructor() {
} }
} }
const val DEFAULT_PAGE_SIZE = 50 const val DEFAULT_PAGE_SIZE = 100
val DefaultItemFields = val DefaultItemFields =
listOf( listOf(

View file

@ -13,11 +13,16 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card import androidx.tv.material3.Card
@ -30,6 +35,7 @@ import com.github.damontecres.dolphin.ui.isNotNullOrBlank
@Composable @Composable
fun BannerCard( fun BannerCard(
name: String?,
imageUrl: String?, imageUrl: String?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
@ -41,19 +47,39 @@ fun BannerCard(
aspectRatio: Float = 16f / 9, aspectRatio: Float = 16f / 9,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {
var imageError by remember { mutableStateOf(false) }
Card( Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight), modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(
AsyncImage( modifier =
model = imageUrl, Modifier
contentDescription = null, .fillMaxSize()
contentScale = ContentScale.Fit, .background(MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxSize(), ) {
) if (!imageError && imageUrl.isNotNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
onError = { imageError = true },
modifier = Modifier.fillMaxSize(),
)
} else {
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(16.dp)
.align(Alignment.Center),
)
}
if (played || cornerText.isNotNullOrBlank()) { if (played || cornerText.isNotNullOrBlank()) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),

View file

@ -0,0 +1,117 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
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.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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 com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.enableMarquee
import kotlinx.coroutines.delay
@Composable
fun GridCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier,
) {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
// showOverlay = !focusedAfterDelay,
showOverlay = true,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
useFallbackText = false,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f) // TODO
.background(MaterialTheme.colorScheme.surfaceVariant),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(),
) {
Text(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.data?.productionYear?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.ui.cards
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -27,10 +28,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -47,6 +51,7 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.util.seasonEpisode import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@ -161,24 +166,64 @@ fun ItemCardImage(
unwatchedCount: Int, unwatchedCount: Int,
watchedPercent: Double?, watchedPercent: Double?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useFallbackText: Boolean = true,
) { ) {
var imageError by remember { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
AsyncImage( if (!imageError && imageUrl.isNotNullOrBlank()) {
model = imageUrl, AsyncImage(
contentDescription = name, model = imageUrl,
contentScale = ContentScale.Fit, contentDescription = name,
alignment = Alignment.Center, contentScale = ContentScale.Fit,
// TODO error/fallback images alignment = Alignment.Center,
error = null, // TODO error/fallback images
fallback = null, error = null,
onError = { fallback = null,
Timber.e(it.result.throwable, "Error loading image: $imageUrl") onError = {
}, Timber.e(it.result.throwable, "Error loading image: $imageUrl")
modifier = imageError = true
Modifier },
.fillMaxSize() modifier =
.align(Alignment.TopCenter), Modifier
) .fillMaxSize()
.align(Alignment.TopCenter),
)
} else {
Box(
modifier =
Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxSize()
.align(Alignment.TopCenter),
) {
if (useFallbackText && name.isNotNullOrBlank()) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier =
Modifier
.padding(8.dp)
.align(Alignment.Center),
)
} else {
Image(
painter = painterResource(id = R.drawable.video_solid),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter =
ColorFilter.tint(
MaterialTheme.colorScheme.onSurfaceVariant,
BlendMode.SrcIn,
),
modifier =
Modifier
.fillMaxSize(.4f)
.align(Alignment.Center),
)
}
}
}
AnimatedVisibility( AnimatedVisibility(
visible = showOverlay, visible = showOverlay,
enter = fadeIn(), enter = fadeIn(),

View file

@ -116,6 +116,7 @@ fun BannerItemRow(
modifier = modifier, modifier = modifier,
cardContent = { index, item, modifier, onClick, onLongClick -> cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard( BannerCard(
name = title,
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
aspectRatio = aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9), aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9),

View file

@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -44,7 +43,7 @@ import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.cards.ItemCard import com.github.damontecres.dolphin.ui.cards.GridCard
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.playback.isBackwardButton import com.github.damontecres.dolphin.ui.playback.isBackwardButton
import com.github.damontecres.dolphin.ui.playback.isForwardButton import com.github.damontecres.dolphin.ui.playback.isForwardButton
@ -53,7 +52,6 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.math.max
private const val DEBUG = false private const val DEBUG = false
@ -63,7 +61,6 @@ fun CardGrid(
itemOnClick: (BaseItem) -> Unit, itemOnClick: (BaseItem) -> Unit,
longClicker: (BaseItem) -> Unit, longClicker: (BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
requestFocus: Boolean,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
showJumpButtons: Boolean, showJumpButtons: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -79,45 +76,6 @@ fun CardGrid(
val zeroFocus = remember { FocusRequester() } val zeroFocus = remember { FocusRequester() }
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
var focusedIndexOnExit by rememberSaveable { mutableIntStateOf(-1) }
// Tracks whether the very first requestFocus has run, if the caller isn't requesting focus,
// then the first time will never run
var hasRequestFocusRun by rememberSaveable { mutableStateOf(!requestFocus) }
var savedFocusedIndex by rememberSaveable { mutableIntStateOf(-1) }
if (DEBUG) {
Timber.d(
"Grid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex",
)
}
LaunchedEffect(Unit) {
if (!hasRequestFocusRun) {
// On very first composition, if parent wants to focus on the grid, scroll to the item
if (requestFocus && initialPosition >= 0) {
if (DEBUG) {
Timber.d(
"focus on startPosition=$startPosition, from initialPosition=$initialPosition",
)
}
focusedIndex = startPosition
gridState.scrollToItem(startPosition, 0)
firstFocus.tryRequestFocus()
}
} else {
val index = savedFocusedIndex
if (DEBUG) Timber.d("savedFocusedIndex=$index")
if (index in 0..<pager.size) {
// If this is a recomposition, but not the first
// focus on the restored index
// gridState.scrollToItem(index, -columns)
firstFocus.tryRequestFocus()
}
savedFocusedIndex = -1
}
// hasRun = true
}
var alphabetFocus by remember { mutableStateOf(false) } var alphabetFocus by remember { mutableStateOf(false) }
val focusOn = { index: Int -> val focusOn = { index: Int ->
@ -129,12 +87,12 @@ fun CardGrid(
} }
// Wait for a recomposition to focus // Wait for a recomposition to focus
LaunchedEffect(alphabetFocus) { // LaunchedEffect(alphabetFocus) {
if (alphabetFocus) { // if (alphabetFocus) {
firstFocus.tryRequestFocus() // firstFocus.tryRequestFocus()
} // }
alphabetFocus = false // alphabetFocus = false
} // }
val useBackToJump = true // uiConfig.preferences.interfacePreferences.scrollTopOnBack val useBackToJump = true // uiConfig.preferences.interfacePreferences.scrollTopOnBack
val showFooter = true // uiConfig.preferences.interfacePreferences.showPositionFooter val showFooter = true // uiConfig.preferences.interfacePreferences.showPositionFooter
@ -169,7 +127,6 @@ fun CardGrid(
val newPosition = val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size) (gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition") if (DEBUG) Timber.d("newPosition=$newPosition")
savedFocusedIndex = newPosition
focusOn(newPosition) focusOn(newPosition)
gridState.scrollToItem(newPosition, 0) gridState.scrollToItem(newPosition, 0)
} }
@ -255,51 +212,38 @@ fun CardGrid(
.fillMaxSize() .fillMaxSize()
.focusGroup() .focusGroup()
.focusRestorer(firstFocus) .focusRestorer(firstFocus)
.focusRequester(gridFocusRequester)
.focusProperties { .focusProperties {
onExit = { onExit = {
// Leaving the grid, so "forget" the position // Leaving the grid, so "forget" the position
focusedIndexOnExit = focusedIndex
focusedIndex = -1 focusedIndex = -1
savedFocusedIndex = -1
} }
onEnter = { onEnter = {
focusedIndexOnExit = -1
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
Timber.d("onEnter: focusedIndex=$focusedIndex, savedFocusedIndex=$savedFocusedIndex")
focusedIndex = startPosition focusedIndex = startPosition
firstFocus.tryRequestFocus()
} }
} }
}, },
) { ) {
items(pager.size) { index -> items(pager.size) { index ->
val mod = val mod =
if (index == savedFocusedIndex) { if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to itemClickedIndex $index")
Modifier.focusRequester(firstFocus)
} else if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier.focusRequester(firstFocus) Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
} else { } else {
Modifier Modifier
} }
val item = pager[index] val item = pager[index]
if (!hasRequestFocusRun && requestFocus && initialPosition >= 0) { GridCard(
// On very first composition, if parent wants to focus on the grid, do so item = item,
LaunchedEffect(Unit) { onClick = {
if (DEBUG) { if (item != null) {
Timber.d( focusedIndex = index
"non-null focus on startPosition=$startPosition, from initialPosition=$initialPosition", itemOnClick.invoke(item)
)
} }
// focus on startPosition },
gridState.scrollToItem(startPosition, 0) onLongClick = { if (item != null) longClicker.invoke(item) },
firstFocus.tryRequestFocus()
hasRequestFocusRun = true
}
}
ItemCard(
modifier = modifier =
mod mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus)) .ifElse(index == 0, Modifier.focusRequester(zeroFocus))
@ -314,20 +258,11 @@ fun CardGrid(
focusOn(index) focusOn(index)
positionCallback?.invoke(columns, index) positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) { } else if (focusedIndex == index) {
savedFocusedIndex = index // savedFocusedIndex = index
// Was focused on this, so mark unfocused // // Was focused on this, so mark unfocused
focusedIndex = -1 // focusedIndex = -1
} }
}, },
item = item,
onClick = {
if (item != null) {
itemOnClick.invoke(item)
savedFocusedIndex = index
}
},
onLongClick = { if (item != null) longClicker.invoke(item) },
cardHeight = null,
) )
} }
} }
@ -349,12 +284,12 @@ fun CardGrid(
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background(AppColors.TransparentBlack50), .background(AppColors.TransparentBlack50),
) { ) {
val index = val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
if (focusedIndex >= 0) { // if (focusedIndex >= 0) {
focusedIndex + 1 // focusedIndex + 1
} else { // } else {
max(savedFocusedIndex, focusedIndexOnExit) + 1 // max(savedFocusedIndex, focusedIndexOnExit) + 1
} // }
Text( Text(
modifier = Modifier.padding(4.dp), modifier = Modifier.padding(4.dp),
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,

View file

@ -243,7 +243,6 @@ fun CollectionDetails(
}, },
longClicker = {}, longClicker = {},
letterPosition = { 0 }, letterPosition = { 0 },
requestFocus = true,
gridFocusRequester = gridFocusRequester, gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference showJumpButtons = false, // TODO add preference
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -226,6 +226,7 @@ fun SeriesOverviewContent(
) )
} }
BannerCard( BannerCard(
name = episode?.name,
imageUrl = episode?.imageUrl, imageUrl = episode?.imageUrl,
aspectRatio = aspectRatio =
episode?.data?.primaryImageAspectRatio?.toFloat() episode?.data?.primaryImageAspectRatio?.toFloat()

View file

@ -39,7 +39,6 @@ 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.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.ui.cards.BannerCard 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
@ -71,7 +70,7 @@ fun HomePage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(), viewModel: HomeViewModel = hiltViewModel(),
) { ) {
OneTimeLaunchedEffect { LaunchedEffect(Unit) {
viewModel.init(preferences) viewModel.init(preferences)
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
@ -96,14 +95,8 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0)) mutableStateOf(RowColumn(0, 0))
} }
var focusedItem = position.let { homeRows.getOrNull(it.row)?.items?.getOrNull(it.column) }
var focusedItem by remember {
mutableStateOf<BaseItem?>(
homeRows.getOrNull(0)?.items?.getOrNull(
0,
),
)
}
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val positionFocusRequester = remember { FocusRequester() } val positionFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -181,6 +174,7 @@ fun HomePageContent(
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling? // TODO better aspect ration handling?
BannerCard( BannerCard(
name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
aspectRatio = (2f / 3f), aspectRatio = (2f / 3f),
cornerText = item?.data?.indexNumber?.let { "E$it" }, cornerText = item?.data?.indexNumber?.let { "E$it" },

View file

@ -20,10 +20,12 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -44,6 +46,7 @@ class HomeViewModel
"Error loading home page", "Error loading home page",
), ),
) { ) {
Timber.d("init HomeViewModel")
val user by api.userApi.getCurrentUser() val user by api.userApi.getCurrentUser()
// val displayPrefs = // val displayPrefs =
// api.displayPreferencesApi // api.displayPreferencesApi
@ -72,68 +75,17 @@ class HomeViewModel
// ) // )
// } // }
val latestMediaIncludes =
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}
val views by api.userViewsApi.getUserViews()
val homeRows = val homeRows =
homeSections homeSections
.mapNotNull { section -> .mapNotNull { section ->
Timber.Forest.v("Loading section: %s", section.name) Timber.Forest.v("Loading section: %s", section.name)
when (section) { when (section) {
HomeSection.LATEST_MEDIA -> { HomeSection.LATEST_MEDIA -> {
latestMediaIncludes.mapNotNull { viewId -> getLatest(user, limit)
val view =
views.items
.firstOrNull { it.id == viewId }
if (view?.collectionType in supportedCollectionTypes) {
val title =
view
?.name
?.let {
"Recently Added in $it"
}
val request =
GetLatestMediaRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = viewId,
groupItems = true,
limit = limit,
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
HomeRow(
section = section,
items = latest,
title = title,
)
} else {
null
}
}
} }
HomeSection.RESUME -> { HomeSection.RESUME -> {
val request = val items = getResume(user.id, limit)
GetResumeItemsRequest(
userId = user.id,
fields = DefaultItemFields,
limit = limit,
includeItemTypes = supportItemKinds,
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.Companion.from(it, api, true) }
listOf( listOf(
HomeRow( HomeRow(
section = section, section = section,
@ -143,22 +95,12 @@ class HomeViewModel
} }
HomeSection.NEXT_UP -> { HomeSection.NEXT_UP -> {
val request =
GetNextUpRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,
enableResumable = false,
enableUserData = true,
enableRewatching = preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
)
val nextUp = val nextUp =
api.tvShowsApi getNextUp(
.getNextUp(request) user.id,
.content limit,
.items preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
.map { BaseItem.Companion.from(it, api, true) } )
listOf( listOf(
HomeRow( HomeRow(
section = section, section = section,
@ -186,4 +128,92 @@ class HomeViewModel
} }
} }
} }
private suspend fun getResume(
userId: UUID,
limit: Int,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
userId = userId,
fields = DefaultItemFields,
limit = limit,
includeItemTypes = supportItemKinds,
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.Companion.from(it, api, true) }
return items
}
private suspend fun getNextUp(
userId: UUID,
limit: Int,
enableRewatching: Boolean,
): List<BaseItem> {
val request =
GetNextUpRequest(
userId = userId,
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,
enableResumable = false,
enableUserData = true,
enableRewatching = enableRewatching,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.Companion.from(it, api, true) }
return nextUp
}
private suspend fun getLatest(
user: UserDto,
limit: Int,
): List<HomeRow> {
val latestMediaIncludes =
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}
val views by api.userViewsApi.getUserViews()
val rows =
latestMediaIncludes
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
.filter { it.collectionType in supportedCollectionTypes }
.map { view ->
val title =
view
?.name
?.let {
"Recently Added in $it"
}
val request =
GetLatestMediaRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = view.id,
groupItems = true,
limit = limit,
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
HomeRow(
section = HomeSection.LATEST_MEDIA,
items = latest,
title = title,
)
}
return rows
}
} }

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="455dp" android:viewportHeight="512" android:viewportWidth="576" android:width="512dp">
<path android:fillColor="#FF000000" android:pathData="M0,128C0,92.7 28.7,64 64,64l256,0c35.3,0 64,28.7 64,64l0,256c0,35.3 -28.7,64 -64,64L64,448c-35.3,0 -64,-28.7 -64,-64L0,128zM559.1,99.8c10.4,5.6 16.9,16.4 16.9,28.2l0,256c0,11.8 -6.5,22.6 -16.9,28.2s-23,5 -32.9,-1.6l-96,-64L416,337.1l0,-17.1 0,-128 0,-17.1 14.2,-9.5 96,-64c9.8,-6.5 22.4,-7.2 32.9,-1.6z"/>
</vector>