Numerous UI tweaks esp cards

This commit is contained in:
Damontecres 2025-10-09 19:14:31 -04:00
parent 616ffe8de3
commit bb517e0857
No known key found for this signature in database
20 changed files with 324 additions and 58 deletions

View file

@ -13,7 +13,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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
@ -39,6 +38,7 @@ import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.util.profile.createDeviceProfile import com.github.damontecres.dolphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -59,8 +59,8 @@ class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.i("MainActivity.onCreate")
setContent { setContent {
val scope = rememberCoroutineScope()
DolphinTheme(true) { DolphinTheme(true) {
Surface( Surface(
modifier = modifier =
@ -80,6 +80,7 @@ class MainActivity : AppCompatActivity() {
appPreferences.currentServerId, appPreferences.currentServerId,
appPreferences.currentUserId, appPreferences.currentUserId,
) )
Timber.d("MainActivity session restored")
} }
isRestoringSession = false isRestoringSession = false
} }

View file

@ -11,6 +11,7 @@ import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import androidx.room.Relation import androidx.room.Relation
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Update
@Entity(tableName = "servers") @Entity(tableName = "servers")
data class JellyfinServer( data class JellyfinServer(
@ -52,10 +53,13 @@ data class JellyfinServerUsers(
@Dao @Dao
interface JellyfinServerDao { interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
fun addServer(vararg servers: JellyfinServer) fun addServer(server: JellyfinServer): Long
@Update(onConflict = OnConflictStrategy.ABORT)
fun updateServer(server: JellyfinServer): Int
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(vararg users: JellyfinUser) fun addUser(user: JellyfinUser)
@Query("DELETE FROM servers WHERE id = :serverId") @Query("DELETE FROM servers WHERE id = :serverId")
fun deleteServer(serverId: String) fun deleteServer(serverId: String)

View file

@ -104,3 +104,5 @@ data class BaseItem(
} }
} }
} }
val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } }

View file

@ -6,6 +6,7 @@ import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.disk.directory import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
@ -22,6 +23,7 @@ fun CoilConfig(
setSingletonImageLoaderFactory { ctx -> setSingletonImageLoaderFactory { ctx ->
ImageLoader ImageLoader
.Builder(ctx) .Builder(ctx)
.memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build())
.diskCache( .diskCache(
DiskCache DiskCache
.Builder() .Builder()

View file

@ -1,10 +1,12 @@
package com.github.damontecres.dolphin.ui package com.github.damontecres.dolphin.ui
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -30,6 +32,19 @@ val DefaultItemFields =
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
) )
val DefaultButtonPadding =
PaddingValues(
start = 12.dp / 2,
top = 10.dp / 2,
end = 16.dp / 2,
bottom = 10.dp / 2,
)
object Cards {
val defaultHeight2x3 = 180.dp
val playedPercentHeight = 6.dp
}
@Preview( @Preview(
device = "spec:parent=tv_1080p", device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535, backgroundColor = 0xFF383535,

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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
@ -31,6 +32,7 @@ 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.ui.AppColors import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.Cards
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
@Composable @Composable
@ -103,7 +105,10 @@ fun BannerCard(
Box( Box(
modifier = modifier =
Modifier Modifier
.background(AppColors.TransparentBlack50), .background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) { ) {
Text( Text(
text = cornerText, text = cornerText,
@ -123,7 +128,7 @@ fun BannerCard(
.background( .background(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(4.dp) .height(Cards.playedPercentHeight)
.fillMaxWidth((playPercent / 100).toFloat()), .fillMaxWidth((playPercent / 100).toFloat()),
) )
} }

View file

@ -89,7 +89,10 @@ fun GridCard(
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(), modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) { ) {
Text( Text(
text = item?.name ?: "", text = item?.name ?: "",

View file

@ -16,7 +16,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape
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
@ -48,6 +48,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.R 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.Cards
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
@ -240,7 +241,7 @@ fun ItemCardImage(
fontFamily = FontAwesome, fontFamily = FontAwesome,
) )
} }
if (watched && watchedPercent?.let { it <= 0 || it >= 100 } == true) { if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) {
Icon( Icon(
imageVector = Icons.Default.CheckCircle, imageVector = Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
@ -248,27 +249,26 @@ fun ItemCardImage(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.size(48.dp) .size(36.dp)
.padding(8.dp), .padding(4.dp),
) )
} }
if (unwatchedCount > 0) { if (unwatchedCount > 0) {
Box( Box(
modifier = modifier =
Modifier Modifier
.padding(8.dp) .padding(4.dp)
.align(Alignment.TopEnd), .background(
MaterialTheme.colorScheme.border,
shape = RoundedCornerShape(25),
).align(Alignment.TopEnd),
) { ) {
Text( Text(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.border,
shape = CircleShape,
),
color = MaterialTheme.colorScheme.onSurface,
text = unwatchedCount.toString(), text = unwatchedCount.toString(),
fontSize = 20.sp, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
) )
} }
} }
@ -281,7 +281,7 @@ fun ItemCardImage(
.background( .background(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(4.dp) .height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()), .fillMaxWidth((percent / 100.0).toFloat()),
) )
} }

View file

@ -36,12 +36,12 @@ fun ItemRow(
modifier: Modifier, modifier: Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) -> Unit = { index, item, modifier, onClick, onLongClick -> ) -> Unit = { index, item, mod, onClick, onLongClick ->
ItemCard( ItemCard(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier, modifier = mod,
) )
}, },
focusPair: FocusPair? = null, focusPair: FocusPair? = null,

View file

@ -0,0 +1,127 @@
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.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.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.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.enableMarquee
import kotlinx.coroutines.delay
@Composable
fun SeasonCard(
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(),
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(),
) {
Text(
text = dto?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
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

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
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
@ -20,7 +21,9 @@ import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.DefaultButtonPadding
import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.ifElse
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -30,6 +33,7 @@ fun PlayButton(
icon: ImageVector, icon: ImageVector,
onClick: (position: Duration) -> Unit, onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
mirrorIcon: Boolean = false,
) { ) {
Button( Button(
onClick = { onClick.invoke(resume) }, onClick = { onClick.invoke(resume) },
@ -39,6 +43,7 @@ fun PlayButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
) )
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
Text( Text(
@ -56,17 +61,19 @@ fun ExpandablePlayButton(
onClick: (position: Duration) -> Unit, onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false,
) { ) {
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
Button( Button(
onClick = { onClick.invoke(resume) }, onClick = { onClick.invoke(resume) },
modifier = modifier, modifier = modifier,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding, contentPadding = DefaultButtonPadding,
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
) )
AnimatedVisibility(isFocused) { AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
@ -89,14 +96,14 @@ fun ExpandableFaButton(
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier.heightIn(min = 44.dp), modifier = modifier.heightIn(min = 40.dp),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding, contentPadding = DefaultButtonPadding,
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Text( Text(
text = stringResource(iconStringRes), text = stringResource(iconStringRes),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontSize = 18.sp, fontSize = 16.sp,
fontFamily = FontAwesome, fontFamily = FontAwesome,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier, modifier = Modifier,

View file

@ -27,8 +27,11 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.PreviewTvSpec
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun PlayButtons( fun PlayButtons(
@ -70,6 +73,7 @@ fun PlayButtons(
playOnClick, playOnClick,
Modifier Modifier
.onFocusChanged(buttonOnFocusChanged), .onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true,
) )
} }
} else { } else {
@ -154,6 +158,7 @@ fun ExpandablePlayButtons(
Icons.Default.Refresh, Icons.Default.Refresh,
playOnClick, playOnClick,
Modifier.onFocusChanged(buttonOnFocusChanged), Modifier.onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true,
) )
} }
} else { } else {
@ -192,3 +197,19 @@ fun ExpandablePlayButtons(
} }
} }
} }
@PreviewTvSpec
@Composable
private fun ExpandablePlayButtonsPreview() {
DolphinTheme(true) {
ExpandablePlayButtons(
resumePosition = 10.seconds,
watched = false,
playOnClick = {},
watchOnClick = {},
moreOnClick = {},
buttonOnFocusChanged = {},
modifier = Modifier,
)
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@ -18,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells 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.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -62,6 +64,7 @@ import timber.log.Timber
private const val DEBUG = false private const val DEBUG = false
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CardGrid( fun CardGrid(
pager: List<BaseItem?>, pager: List<BaseItem?>,
@ -78,7 +81,8 @@ fun CardGrid(
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 6 val columns = 6
val gridState = rememberLazyGridState() val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
val zeroFocus = remember { FocusRequester() } val zeroFocus = remember { FocusRequester() }

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -48,8 +49,10 @@ 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.data.model.Person import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.Cards
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.PersonRow
import com.github.damontecres.dolphin.ui.cards.SeasonCard
import com.github.damontecres.dolphin.ui.components.ConfirmDialog import com.github.damontecres.dolphin.ui.components.ConfirmDialog
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.ErrorMessage
@ -232,6 +235,16 @@ fun SeriesDetailsContent(
modifier = modifier =
Modifier Modifier
.fillMaxWidth(), .fillMaxWidth(),
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
imageHeight = Cards.defaultHeight2x3,
imageWidth = Dp.Unspecified,
)
},
) )
} }
if (people.isNotEmpty()) { if (people.isNotEmpty()) {

View file

@ -118,11 +118,11 @@ class SeriesViewModel
ThemeSongVolume.DISABLED, ThemeSongVolume.DISABLED,
-> return -> return
ThemeSongVolume.LOWEST -> .1f ThemeSongVolume.LOWEST -> .05f
ThemeSongVolume.LOW -> .25f ThemeSongVolume.LOW -> .1f
ThemeSongVolume.MEDIUM -> .5f ThemeSongVolume.MEDIUM -> .25f
ThemeSongVolume.HIGH -> .75f ThemeSongVolume.HIGH -> .5f
ThemeSongVolume.HIGHEST -> 1f ThemeSongVolume.HIGHEST -> 75f
} }
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler()) {
val themeSongs = api.libraryApi.getThemeSongs(seriesId).content val themeSongs = api.libraryApi.getThemeSongs(seriesId).content

View file

@ -43,6 +43,7 @@ import androidx.tv.material3.TabRowDefaults
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.data.model.aspectRatioFloat
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect 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.ifElse import com.github.damontecres.dolphin.ui.ifElse
@ -225,12 +226,17 @@ fun SeriesOverviewContent(
), ),
) )
} }
BannerCard( BannerCard(
name = episode?.name, name = episode?.name,
imageUrl = episode?.imageUrl, imageUrl = episode?.imageUrl,
aspectRatio = aspectRatio =
episode?.data?.primaryImageAspectRatio?.toFloat() episode
?: (16f / 9), ?.data
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(episode.data.aspectRatioFloat ?: (4f / 3f))
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = "E${episode?.data?.indexNumber}", cornerText = "E${episode?.data?.indexNumber}",
played = episode?.data?.userData?.played ?: false, played = episode?.data?.userData?.played ?: false,
playPercent = episode?.data?.userData?.playedPercentage ?: 0.0, playPercent = episode?.data?.userData?.playedPercentage ?: 0.0,

View file

@ -39,6 +39,7 @@ 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.Cards
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
@ -194,7 +195,7 @@ fun HomePageContent(
Modifier.focusRequester(positionFocusRequester), Modifier.focusRequester(positionFocusRequester),
), ),
interactionSource = null, interactionSource = null,
cardHeight = 200.dp, cardHeight = Cards.defaultHeight2x3,
) )
}, },
) )

View file

@ -4,8 +4,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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
@ -25,9 +26,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.Cards
import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.EpisodeCard import com.github.damontecres.dolphin.ui.cards.EpisodeCard
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.cards.SeasonCard
import com.github.damontecres.dolphin.ui.components.EditTextBox import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
@ -125,7 +128,7 @@ fun SearchPage(
val series by viewModel.series.observeAsState(listOf()) val series by viewModel.series.observeAsState(listOf())
val episodes by viewModel.episodes.observeAsState(listOf()) val episodes by viewModel.episodes.observeAsState(listOf())
var query by remember { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
val resultsFocusRequester = remember { FocusRequester() } val resultsFocusRequester = remember { FocusRequester() }
@ -163,22 +166,47 @@ fun SearchPage(
) )
} }
} }
itemsIndexed(listOf(movies, series)) { index, items -> if (movies.isNotEmpty()) {
if (items.isNotEmpty()) { item {
ItemRow( ItemRow(
title = title = "Movies",
when (index) { items = movies,
0 -> "Movies"
1 -> "Series"
2 -> "Episodes"
else -> ""
},
items = items,
onClickItem = { onClickItem = {
viewModel.navigationManager.navigateTo(it.destination()) viewModel.navigationManager.navigateTo(it.destination())
}, },
onLongClickItem = {}, onLongClickItem = {},
modifier = Modifier.focusRequester(resultsFocusRequester), modifier = Modifier,
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.defaultHeight2x3,
modifier = mod,
)
},
)
}
}
if (series.isNotEmpty()) {
item {
ItemRow(
title = "Series",
items = series,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onLongClickItem = {},
modifier = Modifier,
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.defaultHeight2x3,
modifier = mod,
)
},
) )
} }
} }
@ -197,8 +225,8 @@ fun SearchPage(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = 160.dp, imageHeight = 140.dp,
modifier = mod, modifier = mod.padding(horizontal = 8.dp),
) )
}, },
) )

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
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.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -17,9 +18,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -27,7 +31,9 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.ui.components.BasicDialog import com.github.damontecres.dolphin.ui.components.BasicDialog
import com.github.damontecres.dolphin.ui.components.EditTextBox import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.tryRequestFocus
@Composable @Composable
fun SwitchServerContent( fun SwitchServerContent(
@ -95,11 +101,17 @@ fun SwitchServerContent(
} }
BasicDialog( BasicDialog(
onDismissRequest = { showAddServer = false }, onDismissRequest = { showAddServer = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp), modifier =
Modifier
.padding(16.dp)
.fillMaxWidth(.4f),
) { ) {
Text( Text(
text = "Enter Server URL", text = "Enter Server URL",
@ -117,10 +129,14 @@ fun SwitchServerContent(
KeyboardActions( KeyboardActions(
onDone = { submit.invoke() }, onDone = { submit.invoke() },
), ),
modifier = Modifier, modifier =
Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
) )
Button( Button(
onClick = { submit.invoke() }, onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank(),
modifier = Modifier, modifier = Modifier,
) { ) {
Text(text = "Submit") Text(text = "Submit")

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin.ui.setup package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -19,6 +20,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -31,6 +34,7 @@ import com.github.damontecres.dolphin.ui.components.BasicDialog
import com.github.damontecres.dolphin.ui.components.EditTextBox import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.tryRequestFocus
@Composable @Composable
fun SwitchUserContent( fun SwitchUserContent(
@ -152,13 +156,20 @@ fun SwitchUserContent(
viewModel.navigationManager.goToHome() viewModel.navigationManager.goToHome()
} }
} }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier, modifier = Modifier.focusGroup(),
) { ) {
Text( Text(
text = "Username", text = "Username",
modifier = Modifier, modifier = Modifier.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
value = username, value = username,
@ -169,7 +180,7 @@ fun SwitchUserContent(
autoCorrectEnabled = false, autoCorrectEnabled = false,
keyboardType = KeyboardType.Text, keyboardType = KeyboardType.Text,
), ),
modifier = Modifier, modifier = Modifier.focusRequester(focusRequester),
) )
} }
Row( Row(
@ -178,7 +189,7 @@ fun SwitchUserContent(
) { ) {
Text( Text(
text = "Password", text = "Password",
modifier = Modifier, modifier = Modifier.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
value = password, value = password,