UI updates to movie & search pages

This commit is contained in:
Damontecres 2025-10-09 13:01:57 -04:00
parent fe1d63793d
commit 616ffe8de3
No known key found for this signature in database
11 changed files with 235 additions and 41 deletions

View file

@ -7,6 +7,7 @@ import androidx.datastore.core.DataStore
import com.github.damontecres.dolphin.preferences.AppPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userApi
@ -20,6 +21,7 @@ import javax.inject.Singleton
class ServerRepository
@Inject
constructor(
val jellyfin: Jellyfin,
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import coil3.request.ErrorResult
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
@ -253,3 +254,14 @@ fun Activity.keepScreenOn(keep: Boolean) {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
fun logCoilError(
url: String?,
errorResult: ErrorResult,
) {
if (errorResult.throwable is coil3.network.HttpException) {
Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url)
} else {
Timber.e(errorResult.throwable, "Error loading image: %s", url)
}
}

View file

@ -0,0 +1,143 @@
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.coroutines.delay
@Composable
fun EpisodeCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
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
}
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(imageWidth, imageHeight)
.aspectRatio(aspectRatio),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
showOverlay = false,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
useFallbackText = false,
modifier =
Modifier
.fillMaxSize(),
)
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.background(AppColors.TransparentBlack50, shape = RoundedCornerShape(8.dp)),
) {
Text(
text = dto?.seasonEpisode ?: "",
modifier = Modifier.padding(4.dp),
)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(),
) {
Text(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -52,10 +52,10 @@ import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.logCoilError
import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
@Composable
fun ItemCard(
@ -176,11 +176,8 @@ fun ItemCardImage(
contentDescription = name,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
// TODO error/fallback images
error = null,
fallback = null,
onError = {
Timber.e(it.result.throwable, "Error loading image: $imageUrl")
logCoilError(imageUrl, it.result)
imageError = true
},
modifier =
@ -189,6 +186,7 @@ fun ItemCardImage(
.align(Alignment.TopCenter),
)
} else {
// TODO options for overriding fallback
Box(
modifier =
Modifier

View file

@ -4,7 +4,6 @@ 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -63,10 +62,9 @@ fun ItemRow(
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
) {

View file

@ -1,13 +1,13 @@
package com.github.damontecres.dolphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -17,7 +17,6 @@ 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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
@ -32,8 +31,6 @@ fun PersonCard(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 150.dp * .75f,
cardHeight: Dp = 200.dp * .75f,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val hideOverlayDelay = 1_000L
@ -53,34 +50,44 @@ fun PersonCard(
} else {
focusedAfterDelay = false
}
Card(
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
Card(
modifier = Modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
ItemCardImage(
imageUrl = item.imageUrl,
name = item.name,
showOverlay = true,
showOverlay = false,
favorite = false,
watched = false,
unwatchedCount = -1,
watchedPercent = null,
useFallbackText = false,
modifier =
Modifier
.fillMaxWidth()
.height(cardHeight),
.aspectRatio(2f / 3f), // TODO,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item.name ?: "",
maxLines = 1,

View file

@ -5,6 +5,7 @@ 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.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -52,7 +53,10 @@ fun PersonRow(
item = item,
onClick = { onClick.invoke(item) },
onLongClick = { onLongClick?.invoke(item) },
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
modifier =
Modifier
.width(120.dp)
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
}

View file

@ -289,7 +289,8 @@ fun MovieDetailsContent(
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
Modifier
.fillMaxWidth(.7f),
.fillMaxWidth(.7f)
.padding(bottom = 8.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,

View file

@ -57,6 +57,7 @@ fun MovieDetailsHeader(
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
// Title
@ -64,7 +65,7 @@ fun MovieDetailsHeader(
text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displayMedium.copy(
MaterialTheme.typography.displayLarge.copy(
shadow =
Shadow(
color = Color.DarkGray,
@ -93,12 +94,13 @@ fun MovieDetailsHeader(
?.let {
add(it)
}
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
dto.officialRating?.let(::add)
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
}
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.titleLarge,
textStyle = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp),
)
}
dto.communityRating?.let {
@ -109,10 +111,10 @@ fun MovieDetailsHeader(
enabled = false,
precision = StarRatingPrecision.HALF,
playSoundOnFocus = true,
modifier = Modifier.height(40.dp),
modifier = Modifier.height(32.dp),
)
} else {
Spacer(Modifier.height(40.dp))
Spacer(Modifier.height(32.dp))
}
}
@ -157,7 +159,7 @@ fun MovieDetailsHeader(
Row(
modifier =
Modifier
.padding(top = 8.dp, start = 16.dp)
.padding(start = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {

View file

@ -25,6 +25,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.EpisodeCard
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
@ -65,6 +67,7 @@ class SearchViewModel
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE),
fields = DefaultItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
@ -79,6 +82,7 @@ class SearchViewModel
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES),
fields = DefaultItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
@ -93,6 +97,7 @@ class SearchViewModel
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
fields = DefaultItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
@ -134,7 +139,7 @@ fun SearchPage(
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
@ -158,7 +163,7 @@ fun SearchPage(
)
}
}
itemsIndexed(listOf(movies, series, episodes)) { index, items ->
itemsIndexed(listOf(movies, series)) { index, items ->
if (items.isNotEmpty()) {
ItemRow(
title =
@ -177,5 +182,27 @@ fun SearchPage(
)
}
}
if (episodes.isNotEmpty()) {
item {
ItemRow(
title = "Episodes",
items = episodes,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onLongClickItem = {},
modifier = Modifier,
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
EpisodeCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = 160.dp,
modifier = mod,
)
},
)
}
}
}
}