0.2.91
This commit is contained in:
@@ -64,7 +64,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.90"
|
||||
val defaultVersionName = "0.2.91"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1512,9 +1512,9 @@ internal fun HomeScreen(
|
||||
)
|
||||
}
|
||||
MediaRow(
|
||||
modifier = Modifier,
|
||||
row = row,
|
||||
availableWidth = contentWidth,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||
!hasContextualHero &&
|
||||
(selectedDestination != BrowseDestination.SHOWS ||
|
||||
@@ -1773,7 +1773,6 @@ internal fun HomeScreen(
|
||||
),
|
||||
viewers = viewers,
|
||||
activeViewerId = settings.activeViewerId,
|
||||
activeViewerName = settings.activeViewerName,
|
||||
// Selecting a person is the panel's own press now rather than a screen
|
||||
// reached from it, which is the whole point: this is the thing a household
|
||||
// changes nightly. Everything on the launcher belongs to the outgoing
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
|
||||
/**
|
||||
* The quiet surface between the focused title details and the scrolling shelves.
|
||||
*
|
||||
* The right-hand artwork is the focused item's own backdrop, treated as part of the same
|
||||
* surface rather than a card beside it. Text keeps the darker left half; the image emerges
|
||||
* through layered scrims so focus movement feels cinematic without asking the row to own
|
||||
* any new state.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MetadataHero(
|
||||
item: BaseItem?,
|
||||
loading: Boolean,
|
||||
sectionLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.74f),
|
||||
0.70f to MembySurface.copy(alpha = 0.52f),
|
||||
1f to MembySurface.copy(alpha = 0.18f),
|
||||
),
|
||||
),
|
||||
) {
|
||||
MetadataHeroArtwork(
|
||||
item = item,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.94f),
|
||||
0.34f to MembySurface.copy(alpha = 0.90f),
|
||||
0.60f to MembySurface.copy(alpha = 0.74f),
|
||||
0.82f to MembySurface.copy(alpha = 0.38f),
|
||||
1f to MembySurface.copy(alpha = 0.18f),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.28f),
|
||||
0.74f to Color.Transparent,
|
||||
1f to MembySurface.copy(alpha = 0.24f),
|
||||
),
|
||||
),
|
||||
)
|
||||
MediaMetadataPanel(
|
||||
item = item,
|
||||
loading = loading,
|
||||
sectionLabel = sectionLabel,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = HomeContentHorizontalInset,
|
||||
end = HomeContentHorizontalInset,
|
||||
top = 28.dp,
|
||||
bottom = 18.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataHeroArtwork(
|
||||
item: BaseItem?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repository = ServiceLocator.repository
|
||||
val artwork = remember(
|
||||
item?.id,
|
||||
item?.backdropImageTags,
|
||||
item?.parentBackdropItemId,
|
||||
item?.parentBackdropImageTags,
|
||||
item?.imageTags,
|
||||
) {
|
||||
item?.let { repository.backdropUrl(it, maxWidth = 1280) ?: repository.primaryUrl(it, maxWidth = 960) }
|
||||
}
|
||||
val request = remember(artwork, context) {
|
||||
artwork?.let {
|
||||
ImageRequest.Builder(context)
|
||||
.data(it)
|
||||
.size(1280, 720)
|
||||
.allowHardware(true)
|
||||
.crossfade(MetadataHeroArtworkCrossfadeMs)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
if (request == null) return
|
||||
Box(modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxWidth(MetadataHeroArtworkWidthFraction)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to MembySurface,
|
||||
0.16f to MembySurface.copy(alpha = 0.96f),
|
||||
0.42f to MembySurface.copy(alpha = 0.70f),
|
||||
0.76f to MembySurface.copy(alpha = 0.24f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.28f),
|
||||
0.58f to Color.Transparent,
|
||||
1f to MembySurface.copy(alpha = 0.48f),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MetadataHeroArtworkCrossfadeMs = 180
|
||||
private const val MetadataHeroArtworkWidthFraction = 0.58f
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun FocusScaleContainer(
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
content: @Composable BoxScope.(focused: Boolean) -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale = animateFloatAsState(
|
||||
targetValue = if (focused) 1.018f else 1f,
|
||||
animationSpec = tween(95),
|
||||
label = "media-card-focus",
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.zIndex(if (focused) 1f else 0f)
|
||||
// Always a graphicsLayer, and the scale read inside it. Branching on
|
||||
// `scale != 1f` meant reading the animated value during composition, so
|
||||
// every card recomposed on each frame of its own focus animation.
|
||||
.graphicsLayer {
|
||||
scaleX = scale.value
|
||||
scaleY = scale.value
|
||||
}
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
// The hold, the swallowed repeats and the cancel-on-focus-loss are
|
||||
// [Modifier.remoteLongPress]'s — shared with the rail's user item, so the length
|
||||
// of press that opens a menu is the same wherever a menu can be opened.
|
||||
.remoteLongPress(onClick = onClick, onLongClick = onLongClick)
|
||||
.semantics(mergeDescendants = true) {
|
||||
this.contentDescription = contentDescription
|
||||
if (onLongClick != null) {
|
||||
onLongClick("Quick actions") {
|
||||
onLongClick()
|
||||
true
|
||||
}
|
||||
}
|
||||
},
|
||||
content = { content(focused) },
|
||||
)
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
fun ContinueWatchingCard(
|
||||
item: BaseItem,
|
||||
availableWidth: Dp,
|
||||
portraitArtwork: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
density: String = "standard",
|
||||
) {
|
||||
val cardsAcross = if (portraitArtwork) {
|
||||
when (density) {
|
||||
"compact" -> 8
|
||||
"large" -> 5
|
||||
else -> 7
|
||||
}
|
||||
} else {
|
||||
when (density) {
|
||||
"compact" -> 5
|
||||
"large" -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
val width = if (portraitArtwork) {
|
||||
responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
} else {
|
||||
responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
|
||||
}
|
||||
val repository = ServiceLocator.repository
|
||||
val model = remember(item) {
|
||||
item.toResumableMediaCardModel(
|
||||
backdropUrl = repository.backdropUrl(item, 720),
|
||||
primaryUrl = repository.primaryUrl(item, 480),
|
||||
)
|
||||
}
|
||||
ResumableMediaCard(
|
||||
model = model,
|
||||
width = width,
|
||||
portraitArtwork = portraitArtwork,
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
fun LandscapeCard(
|
||||
item: BaseItem,
|
||||
availableWidth: Dp,
|
||||
showSecondaryMetadata: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
density: String = "standard",
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
) {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 5
|
||||
"large" -> 3
|
||||
else -> 4
|
||||
}
|
||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
|
||||
MediaCard(
|
||||
item, width, 16f / 9f, preferPrimary = false, showProgress = false,
|
||||
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
@Composable
|
||||
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
label,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
.border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
|
||||
.background(Color.Black.copy(alpha = 0.22f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
val color = when (status) {
|
||||
"available" -> EmbyGreen
|
||||
"downloading" -> Color(0xFF5DA9FF)
|
||||
"awaiting" -> Color(0xFFFFB454)
|
||||
"unmonitored" -> QuietText
|
||||
else -> MembyOnSurface
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = if (status == "available") MembyAccentInk else MembySurface,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun scheduleStatusBadgeLabel(status: String): String? = when (status) {
|
||||
"available" -> "ADDED"
|
||||
"downloading" -> "DOWNLOADING"
|
||||
"awaiting" -> "AWAITING"
|
||||
"unmonitored" -> "UNMONITORED"
|
||||
// The day badge and air-time subtitle already say when a future episode airs.
|
||||
// Repeating "upcoming" adds no state and can collide with the more useful day.
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The show's or film's lifecycle, as Sonarr and Radarr word it — CONTINUING, ENDED, IN
|
||||
* CINEMAS. It answers a different question from the availability badge above it: that one
|
||||
* is about the household's copy of this episode, this one is about whether there will be
|
||||
* any more of them.
|
||||
*
|
||||
* The wording is the gateway's, taken verbatim off the card, so a status a build predates
|
||||
* still reads correctly rather than falling back to a slug. A card carrying no lifecycle —
|
||||
* an older gateway, a cached row, a show *arr has no status for — simply wears no tag.
|
||||
*/
|
||||
internal fun lifecycleBadgeLabel(item: BaseItem): String? =
|
||||
item.membyLifecycleText?.trim()?.takeIf(String::isNotEmpty)?.uppercase()
|
||||
|
||||
@Composable
|
||||
internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
// One colour per status, matching what the same word is coloured in *arr: still being
|
||||
// made is green, over is red, not out yet is blue, in cinemas is amber.
|
||||
val (background, foreground) = when (status) {
|
||||
"continuing", "released" -> EmbyGreen to MembyAccentInk
|
||||
"upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface
|
||||
"incinemas" -> Color(0xFFFFB454) to MembySurface
|
||||
"ended" -> Color(0xFFE04747) to Color.White
|
||||
else -> MembyControlSurfaceRaised to MembyOnSurface
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = foreground,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(background)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
/**
|
||||
* The size of the media-type mark on a card's metadata line.
|
||||
*
|
||||
* Deliberately smaller than any icon a viewer can aim at — the rail's marks are 21dp and
|
||||
* the circular detail actions larger still. This one is read, never pressed, and at 13dp
|
||||
* it sits inside the 12sp line's own height, so a card carrying it is exactly as tall as
|
||||
* one that does not.
|
||||
*/
|
||||
private val MediaTypeMarkSize = 13.dp
|
||||
|
||||
/**
|
||||
* Which of the two kinds of thing a card is, for a grid that holds both.
|
||||
*
|
||||
* Only the Genres destination genuinely mixes them — the Movies and TV Shows pages name a
|
||||
* type, so every card on those wears the same mark and it says nothing. It is a *mark*
|
||||
* rather than a word because the metadata line is one line at three metres, and "TV SHOW"
|
||||
* spelled out there would crowd out the year and the runtime it sits beside.
|
||||
*
|
||||
* It belongs to that line rather than to the poster: the type is informational in the same
|
||||
* way the year and the runtime are, and grouping the three together leaves the artwork —
|
||||
* the only thing on the card worth looking at — carrying nothing it did not come with.
|
||||
*
|
||||
* A season or an episode is marked as television: both are parts of a show, and a viewer
|
||||
* separating films from shows is not asking about the difference between them. Anything
|
||||
* else — a person, a collection, a schedule card — gets nothing rather than a guess.
|
||||
*/
|
||||
internal fun mediaTypeMark(item: BaseItem): Pair<ImageVector, String>? = when {
|
||||
item.isSeries || item.isEpisode || item.type.equals("Season", ignoreCase = true) ->
|
||||
MembyIcon.Tv.mark to "TV show"
|
||||
item.type.equals("Movie", ignoreCase = true) -> MembyIcon.Movie.mark to "Film"
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun recommendationReasonSummary(reason: String): String =
|
||||
reason
|
||||
.split('·', '•')
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.firstOrNull(String::isNotEmpty)
|
||||
?: reason.trim()
|
||||
|
||||
@Composable
|
||||
internal fun RecommendationReasonCaption(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = EmbyGreen.copy(alpha = 0.88f),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun mediaBadges(item: BaseItem): List<String> {
|
||||
val video = item.mediaStreams.firstOrNull { it.type.equals("Video", true) }
|
||||
val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", true) }
|
||||
return buildList {
|
||||
if ((video?.width ?: 0) >= UHD_MIN_WIDTH) add("4K")
|
||||
// Same reading of the streams as the detail page's spec row, so a file cannot be
|
||||
// HDR10+ in one place and plain HDR in the other.
|
||||
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
|
||||
?.let { add(it.uppercase(Locale.US)) }
|
||||
if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC")
|
||||
audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) }
|
||||
if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS")
|
||||
}.distinct()
|
||||
}
|
||||
|
||||
internal fun watchedEpisodeCountLabel(item: BaseItem): String? {
|
||||
if (!item.isSeries) return null
|
||||
val total = item.recursiveItemCount?.takeIf { it > 0 } ?: return null
|
||||
val unwatched = item.userData?.unplayedItemCount?.coerceIn(0, total) ?: return null
|
||||
val watched = total - unwatched
|
||||
return "$watched of $total watched"
|
||||
}
|
||||
|
||||
private fun cardSubtitle(
|
||||
item: BaseItem,
|
||||
showProgress: Boolean,
|
||||
positionTicks: Long,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
): String = when {
|
||||
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
|
||||
showWatchedEpisodeCount -> watchedEpisodeCountLabel(item)
|
||||
?: defaultCardSubtitle(item)
|
||||
else -> defaultCardSubtitle(item)
|
||||
}
|
||||
|
||||
internal fun defaultCardSubtitle(item: BaseItem): String = when {
|
||||
item.isSchedule -> item.membyAirLabel ?: "Coming up"
|
||||
item.isEpisode -> item.seriesName ?: "Up next"
|
||||
item.productionYear != null && item.runtimeMinutes != null ->
|
||||
"${item.productionYear}$FactSeparator${formatRuntime(requireNotNull(item.runtimeMinutes))}"
|
||||
item.productionYear != null -> item.productionYear.toString()
|
||||
item.isSeries -> "Series"
|
||||
else -> item.type
|
||||
}
|
||||
|
||||
internal fun airingBadgeLabel(item: BaseItem): String? = when {
|
||||
item.isSchedule -> item.membyAirDayLabel
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.uppercase()
|
||||
item.membyAiringToday -> "AIRING TODAY"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun cardDescription(
|
||||
item: BaseItem,
|
||||
progress: Float,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
showMediaTypeIcon: Boolean = false,
|
||||
): String = buildString {
|
||||
append(item.name)
|
||||
if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) ->
|
||||
append(", ").append(description)
|
||||
}
|
||||
item.seriesName?.let { append(", ").append(it) }
|
||||
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
||||
if (item.userData?.played == true) append(", watched")
|
||||
if (showWatchedEpisodeCount) watchedEpisodeCountLabel(item)?.let { append(", ").append(it) }
|
||||
if (item.isFavorite) append(", favourite")
|
||||
if (item.isSchedule) {
|
||||
item.membyAirLabel?.let { append(", ").append(it) }
|
||||
item.membyAvailabilityText?.let { append(", ").append(it) }
|
||||
item.membyLifecycleText?.let { append(", ").append(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTvPosition(ticks: Long): String {
|
||||
val minutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0)
|
||||
return formatRuntime(minutes)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private val MediaRowHorizontalInset = HomeContentHorizontalInset
|
||||
private val MediaRowCardSpacing = 16.dp
|
||||
|
||||
/**
|
||||
* Sizes a shelf to an integer number of complete cards.
|
||||
*
|
||||
* Work from the actual row width, including the launcher inset, so the first card, the
|
||||
* heading and the metadata panel stay on one column while the opposite inset still leaves
|
||||
* a deliberate sliver of the next card visible as a browse cue.
|
||||
*/
|
||||
internal fun responsiveRowCardWidth(
|
||||
availableWidth: Dp,
|
||||
preferredCardsAcross: Int,
|
||||
minWidth: Dp,
|
||||
maxWidth: Dp,
|
||||
horizontalInset: Dp = MediaRowHorizontalInset,
|
||||
spacing: Dp = MediaRowCardSpacing,
|
||||
): Dp {
|
||||
val usableWidth = (availableWidth - horizontalInset * 2).coerceAtLeast(1.dp)
|
||||
val cardsThatFitMinimum =
|
||||
((usableWidth + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
|
||||
var cardCount = preferredCardsAcross.coerceIn(1, cardsThatFitMinimum)
|
||||
|
||||
// Very wide viewports should gain another complete card instead of a large dead area.
|
||||
while (cardCount < cardsThatFitMinimum) {
|
||||
val candidate = (usableWidth - spacing * (cardCount - 1)) / cardCount
|
||||
if (candidate <= maxWidth) break
|
||||
cardCount += 1
|
||||
}
|
||||
|
||||
return ((usableWidth - spacing * (cardCount - 1)) / cardCount)
|
||||
.coerceAtMost(maxWidth)
|
||||
.coerceAtLeast(1.dp)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
@Composable
|
||||
private fun MetadataLoadingSkeleton(contentWidth: Dp) {
|
||||
Column(
|
||||
modifier = Modifier.width(contentWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
SkeletonBlock(contentWidth * 0.18f, 11.dp)
|
||||
SkeletonBlock(contentWidth * 0.62f, 30.dp)
|
||||
SkeletonBlock(contentWidth * 0.46f, 14.dp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
SkeletonBlock(contentWidth, 13.dp)
|
||||
SkeletonBlock(contentWidth * 0.86f, 13.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBlock(width: Dp, height: Dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MembyControlSurfaceRaised),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvLoadingPlaceholder(
|
||||
portrait: Boolean,
|
||||
availableWidth: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val width = if (portrait) {
|
||||
responsiveRowCardWidth(availableWidth, 7, 102.dp, 218.dp)
|
||||
} else {
|
||||
responsiveRowCardWidth(availableWidth, 4, 164.dp, 360.dp)
|
||||
}
|
||||
Column(modifier.width(width)) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(width)
|
||||
.aspectRatio(if (portrait) 2f / 3f else 16f / 9f)
|
||||
.clip(RoundedCornerShape(MembyCardCorner)),
|
||||
) {
|
||||
ArtworkLoadingSkeleton(Modifier.fillMaxSize())
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SkeletonBlock(width * 0.72f, 11.dp)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
SkeletonBlock(width * 0.48f, 8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
MembyControlSurface,
|
||||
MembyControlSurfaceRaised,
|
||||
MembyControlSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
/**
|
||||
* The size of the media-type mark on a card's metadata line.
|
||||
*
|
||||
* Deliberately smaller than any icon a viewer can aim at — the rail's marks are 21dp and
|
||||
* the circular detail actions larger still. This one is read, never pressed, and at 13dp
|
||||
* it sits inside the 12sp line's own height, so a card carrying it is exactly as tall as
|
||||
* one that does not.
|
||||
*/
|
||||
private val MediaTypeMarkSize = 13.dp
|
||||
|
||||
@Composable
|
||||
fun PosterCard(
|
||||
item: BaseItem,
|
||||
availableWidth: Dp,
|
||||
showSecondaryMetadata: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
density: String = "standard",
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
showProgress: Boolean = false,
|
||||
) {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 8
|
||||
"large" -> 5
|
||||
else -> 7
|
||||
}
|
||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
MediaCard(
|
||||
item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress,
|
||||
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A poster card at an explicit width, for grids rather than rows.
|
||||
*
|
||||
* [PosterCard] derives its width from the row's available width, which is the
|
||||
* wrong input for a grid whose column count is already decided. Same card underneath —
|
||||
* same artwork loading, badges and focus treatment.
|
||||
*/
|
||||
@Composable
|
||||
fun PosterGridCard(
|
||||
item: BaseItem,
|
||||
width: Dp,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
showMediaTypeIcon: Boolean = false,
|
||||
) {
|
||||
MediaCard(
|
||||
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
|
||||
showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
||||
showMediaTypeIcon = showMediaTypeIcon,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
private fun MediaCard(
|
||||
item: BaseItem,
|
||||
width: Dp,
|
||||
aspectRatio: Float,
|
||||
preferPrimary: Boolean,
|
||||
showProgress: Boolean,
|
||||
showSecondaryMetadata: Boolean,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
showMediaTypeIcon: Boolean = false,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val density = LocalDensity.current
|
||||
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
|
||||
val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1)
|
||||
val image = remember(item.id, widthPx, preferPrimary) {
|
||||
if (preferPrimary) {
|
||||
repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit }
|
||||
?: repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop }
|
||||
} else {
|
||||
repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop }
|
||||
?: repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit }
|
||||
}
|
||||
}
|
||||
val imageUrl = image?.first
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
val progress = if (runtime > 0L) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
|
||||
var failed by remember(item.id, imageUrl) { mutableStateOf(false) }
|
||||
var loading by remember(item.id, imageUrl) { mutableStateOf(imageUrl != null) }
|
||||
val context = LocalContext.current
|
||||
val imageRequest = remember(imageUrl, widthPx, heightPx, context) {
|
||||
imageUrl?.let {
|
||||
ImageRequest.Builder(context)
|
||||
.data(it)
|
||||
.size(widthPx, heightPx)
|
||||
.allowHardware(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon),
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.width(width)
|
||||
.aspectRatio(aspectRatio)
|
||||
// Keep the modifier node stable while focus changes. Adding/removing
|
||||
// the shadow node forces the poster subtree to be rebuilt exactly
|
||||
// when a row is moving into view, which shows up as a small hitch on
|
||||
// lower-powered TV hardware.
|
||||
.shadow(
|
||||
elevation = if (focused) 5.dp else 0.dp,
|
||||
shape = RoundedCornerShape(MembyCardCorner),
|
||||
)
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.background(MembySurfaceRaised)
|
||||
.border(
|
||||
if (focused) 1.5.dp else 1.dp,
|
||||
if (focused) Color.White.copy(alpha = 0.76f) else Color.White.copy(alpha = 0.06f),
|
||||
RoundedCornerShape(MembyCardCorner),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (loading) {
|
||||
ArtworkLoadingSkeleton(Modifier.fillMaxSize())
|
||||
}
|
||||
if (imageRequest != null) {
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
contentScale = image?.second ?: ContentScale.Crop,
|
||||
onLoading = { loading = true },
|
||||
onSuccess = {
|
||||
failed = false
|
||||
loading = false
|
||||
},
|
||||
onError = {
|
||||
failed = true
|
||||
loading = false
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (imageUrl == null || failed) {
|
||||
Icon(
|
||||
MembyIcon.BrokenImage.mark,
|
||||
contentDescription = "Artwork unavailable",
|
||||
tint = QuietText,
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
if (showProgress && progress > 0f) {
|
||||
Box(
|
||||
Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(5.dp)
|
||||
.background(Color.Black.copy(alpha = 0.65f)),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(progress).height(5.dp).background(EmbyGreen))
|
||||
}
|
||||
}
|
||||
if (item.userData?.played == true || item.isFavorite) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (item.userData?.played == true) {
|
||||
MediaStatusIcon(
|
||||
icon = MembyIcon.CheckCircle.mark,
|
||||
description = "Watched",
|
||||
tint = EmbyGreen,
|
||||
)
|
||||
}
|
||||
if (item.isFavorite) {
|
||||
MediaStatusIcon(
|
||||
icon = MembyIcon.Favourite.mark,
|
||||
description = "Favourite",
|
||||
tint = Color(0xFFFF6B81),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.isSchedule) {
|
||||
scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let {
|
||||
ScheduleStatusBadge(
|
||||
status = item.membyAvailability.orEmpty(),
|
||||
label = it,
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
)
|
||||
}
|
||||
// Bottom-left, because the two top corners already carry the air day and
|
||||
// this episode's availability, and the progress bar owns the bottom edge
|
||||
// only on cards that can be resumed — which a schedule card cannot.
|
||||
lifecycleBadgeLabel(item)?.let {
|
||||
LifecycleBadge(
|
||||
status = item.membyLifecycle.orEmpty(),
|
||||
label = it,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
val airingLabel = airingBadgeLabel(item)
|
||||
if (airingLabel != null) {
|
||||
MediaBadge(
|
||||
airingLabel,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
|
||||
)
|
||||
}
|
||||
if (focused) {
|
||||
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.name,
|
||||
color = if (focused) Color.White else MembyOnSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 9.dp).fillMaxWidth(),
|
||||
)
|
||||
if (showSecondaryMetadata) {
|
||||
// The media type sits *in* the metadata line rather than over the poster:
|
||||
// it is informational, like the year and the runtime beside it, and the
|
||||
// artwork is the one thing on the card worth looking at. The row is
|
||||
// vertically centred and the mark is smaller than the line it sits on, so
|
||||
// it cannot make the card taller than one without it.
|
||||
val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 4.dp).fillMaxWidth(),
|
||||
) {
|
||||
typeMark?.let { (icon, _) ->
|
||||
Icon(
|
||||
icon,
|
||||
// The card's own description already names the type; a second
|
||||
// reading of it here would be the same fact said twice.
|
||||
contentDescription = null,
|
||||
tint = EmbyGreen,
|
||||
modifier = Modifier.size(MediaTypeMarkSize),
|
||||
)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Text(
|
||||
cardSubtitle(item, showProgress, position, showWatchedEpisodeCount),
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaStatusIcon(
|
||||
icon: ImageVector,
|
||||
description: String,
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(29.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.78f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = description,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cardSubtitle(
|
||||
item: BaseItem,
|
||||
showProgress: Boolean,
|
||||
positionTicks: Long,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
): String = when {
|
||||
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
|
||||
showWatchedEpisodeCount -> watchedEpisodeCountLabel(item)
|
||||
?: defaultCardSubtitle(item)
|
||||
else -> defaultCardSubtitle(item)
|
||||
}
|
||||
|
||||
private fun cardDescription(
|
||||
item: BaseItem,
|
||||
progress: Float,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
showMediaTypeIcon: Boolean = false,
|
||||
): String = buildString {
|
||||
append(item.name)
|
||||
if (showMediaTypeIcon) mediaTypeMark(item)?.let { (_, description) ->
|
||||
append(", ").append(description)
|
||||
}
|
||||
item.seriesName?.let { append(", ").append(it) }
|
||||
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
|
||||
if (item.userData?.played == true) append(", watched")
|
||||
if (showWatchedEpisodeCount) watchedEpisodeCountLabel(item)?.let { append(", ").append(it) }
|
||||
if (item.isFavorite) append(", favourite")
|
||||
if (item.isSchedule) {
|
||||
item.membyAirLabel?.let { append(", ").append(it) }
|
||||
item.membyAvailabilityText?.let { append(", ").append(it) }
|
||||
item.membyLifecycleText?.let { append(", ").append(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTvPosition(ticks: Long): String {
|
||||
val minutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0)
|
||||
return formatRuntime(minutes)
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
private val BACKDROP_SETTLE_DELAY = 240.milliseconds
|
||||
|
||||
@Composable
|
||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val repo = ServiceLocator.repository
|
||||
val imageUrl = item?.let { repo.backdropUrl(it, 1280) ?: repo.primaryUrl(it, 960) }
|
||||
var displayedUrl by remember { mutableStateOf(imageUrl) }
|
||||
LaunchedEffect(imageUrl) {
|
||||
if (displayedUrl == null) {
|
||||
displayedUrl = imageUrl
|
||||
} else {
|
||||
delay(BACKDROP_SETTLE_DELAY)
|
||||
displayedUrl = imageUrl
|
||||
}
|
||||
}
|
||||
val request = remember(displayedUrl, context) {
|
||||
displayedUrl?.let {
|
||||
ImageRequest.Builder(context)
|
||||
.data(it)
|
||||
.size(1280, 720)
|
||||
.allowHardware(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
Box(modifier.background(MembySurface)) {
|
||||
if (request != null) {
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to MembySurface,
|
||||
0.58f to MembySurface.copy(alpha = 0.89f),
|
||||
1f to MembySurface.copy(alpha = 0.65f),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.45f),
|
||||
0.66f to MembySurface.copy(alpha = 0.84f),
|
||||
1f to MembySurface,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaMetadataPanel(
|
||||
item: BaseItem?,
|
||||
loading: Boolean,
|
||||
sectionLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val compactLayout = maxHeight < 260.dp
|
||||
val contentWidth = metadataPanelContentWidth(maxWidth, compactLayout)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().fillMaxWidth()) {
|
||||
when {
|
||||
item == null && loading -> MetadataLoadingSkeleton(contentWidth)
|
||||
item == null -> {
|
||||
Column(
|
||||
modifier = Modifier.width(contentWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 7.dp else 10.dp),
|
||||
) {
|
||||
Text(sectionLabel, color = QuietText, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"Your library",
|
||||
color = Color.White,
|
||||
fontSize = if (compactLayout) 25.sp else 29.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text("Choose something to watch.", color = MutedText, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
else -> MetadataContent(item, sectionLabel, contentWidth, compactLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp =
|
||||
(availableWidth * if (compact) 0.84f else 0.76f)
|
||||
.coerceIn(280.dp, 680.dp)
|
||||
.coerceAtMost(availableWidth)
|
||||
|
||||
@Composable
|
||||
private fun MetadataLoadingSkeleton(contentWidth: Dp) {
|
||||
Column(
|
||||
modifier = Modifier.width(contentWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
SkeletonBlock(contentWidth * 0.18f, 11.dp)
|
||||
SkeletonBlock(contentWidth * 0.62f, 30.dp)
|
||||
SkeletonBlock(contentWidth * 0.46f, 14.dp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
SkeletonBlock(contentWidth, 13.dp)
|
||||
SkeletonBlock(contentWidth * 0.86f, 13.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBlock(width: Dp, height: Dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MembyControlSurfaceRaised),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataContent(
|
||||
item: BaseItem,
|
||||
sectionLabel: String,
|
||||
contentWidth: Dp,
|
||||
compact: Boolean,
|
||||
) {
|
||||
if (item.isSchedule) {
|
||||
ScheduleMetadataContent(item, sectionLabel, contentWidth, compact)
|
||||
return
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp),
|
||||
) {
|
||||
Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.1.sp)
|
||||
Text(
|
||||
item.seriesName?.takeIf { item.isEpisode } ?: item.name,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 25.sp else 29.sp,
|
||||
lineHeight = if (compact) 28.sp else 32.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item.isEpisode && item.seriesName != null) {
|
||||
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val facts = buildList {
|
||||
item.episodeCode?.let(::add)
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) }
|
||||
}
|
||||
// No score on this line. Emby's community rating stood beside these facts in gold
|
||||
// with nothing saying whose number it was; the strip below says that for every
|
||||
// score it shows, and two ratings in one panel only invited the comparison.
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ItemRatingsStrip(
|
||||
item = item,
|
||||
load = true,
|
||||
compact = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val badges = buildList {
|
||||
airingBadgeLabel(item)?.let(::add)
|
||||
addAll(mediaBadges(item))
|
||||
}
|
||||
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
|
||||
RecommendationReasonCaption(
|
||||
text = recommendationReasonSummary(it),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (badges.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
badges.forEach { MediaBadge(it) }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = if (compact) 2 else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MetadataStatus(item)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleMetadataContent(
|
||||
item: BaseItem,
|
||||
sectionLabel: String,
|
||||
contentWidth: Dp,
|
||||
compact: Boolean,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp),
|
||||
) {
|
||||
Text(
|
||||
sectionLabel.uppercase(),
|
||||
color = EmbyGreen,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 25.sp else 29.sp,
|
||||
lineHeight = if (compact) 28.sp else 32.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item.isTvSchedule) {
|
||||
Text(
|
||||
listOfNotNull(
|
||||
item.membyEpisodeCode,
|
||||
item.membyEpisodeTitle?.takeIf(String::isNotBlank),
|
||||
).joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
listOfNotNull(
|
||||
item.productionYear?.toString(),
|
||||
item.runtimeMinutes?.let(::formatRuntime),
|
||||
item.genres.take(2).joinToString(ValueSeparator).takeIf(String::isNotBlank),
|
||||
).joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) }
|
||||
item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let {
|
||||
MediaBadge(it.uppercase())
|
||||
}
|
||||
}
|
||||
Text(
|
||||
item.overview?.takeIf(String::isNotBlank)
|
||||
?: if (item.isMovieSchedule) {
|
||||
"Movie details will appear when they become available."
|
||||
} else {
|
||||
"Episode details will appear when they become available."
|
||||
},
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = if (compact) 2 else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
if (item.isMovieSchedule) "Upcoming digital releases" else "TV this week",
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataStatus(item: BaseItem) {
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
val progress = if (runtime > 0) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (progress > 0f) {
|
||||
Box(
|
||||
Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(progress).height(4.dp).background(EmbyGreen, RoundedCornerShape(2.dp)))
|
||||
}
|
||||
Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp)
|
||||
}
|
||||
if (item.userData?.played == true) {
|
||||
Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
|
||||
Text("Watched", color = MutedText, fontSize = 13.sp)
|
||||
}
|
||||
if (item.isFavorite) {
|
||||
Icon(MembyIcon.Favourite.mark, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp))
|
||||
Text("Favourite", color = MutedText, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
internal val TvRailCollapsedWidth = 54.dp
|
||||
internal val TvRailExpandedWidth = 184.dp
|
||||
internal val HomeContentHorizontalInset = 48.dp
|
||||
|
||||
/**
|
||||
* The rail's order, which is this declaration order — see [navigationRailItems], the only
|
||||
* thing that reads it. Nothing persists an ordinal, so an entry may be moved.
|
||||
*
|
||||
* The two ends are the fixed points: browsing the catalogue sits directly under Home
|
||||
* because it is the whole library and therefore the widest question a viewer can ask,
|
||||
* while For You is a *narrow* one — a handful of ranked titles — so it belongs down beside
|
||||
* the calendar with the other answers rather than above the shelves it draws from.
|
||||
*/
|
||||
enum class BrowseDestination(val label: String, val icon: MembyIcon) {
|
||||
HOME("Home", MembyIcon.Home),
|
||||
// The catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has
|
||||
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
|
||||
GENRES("Genres", MembyIcon.Grid),
|
||||
SEARCH("Search", MembyIcon.Search),
|
||||
MOVIES("Movies", MembyIcon.Movie),
|
||||
SHOWS("TV Shows", MembyIcon.Tv),
|
||||
FOR_YOU("For You", MembyIcon.Sparkle),
|
||||
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||
CALENDAR("TV Calendar", MembyIcon.Calendar),
|
||||
FAVORITES("Favourites", MembyIcon.Favourite),
|
||||
PROFILES("User", MembyIcon.Person),
|
||||
SETTINGS("Settings", MembyIcon.Settings),
|
||||
}
|
||||
|
||||
/**
|
||||
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
|
||||
* Home while the destinations below it may change with server capabilities.
|
||||
*/
|
||||
internal fun navigationRailItems(
|
||||
calendarEnabled: Boolean,
|
||||
genresEnabled: Boolean = true,
|
||||
): List<BrowseDestination> =
|
||||
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
|
||||
it != BrowseDestination.PROFILES &&
|
||||
it != BrowseDestination.SETTINGS &&
|
||||
(it != BrowseDestination.CALENDAR || calendarEnabled) &&
|
||||
(it != BrowseDestination.GENRES || genresEnabled)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun TvNavigationRail(
|
||||
modifier: Modifier = Modifier,
|
||||
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
|
||||
selected: BrowseDestination,
|
||||
focusDestination: BrowseDestination = selected,
|
||||
expanded: Boolean,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
onRailFocusChanged: (Boolean) -> Unit,
|
||||
onDestinationSelected: (BrowseDestination) -> Unit,
|
||||
alertCount: Int = 0,
|
||||
activeUsername: String = "",
|
||||
activeProfileInitials: String = "",
|
||||
calendarEnabled: Boolean = false,
|
||||
genresEnabled: Boolean = true,
|
||||
/**
|
||||
* A hold on the user item, which is what raises the shortcut menu over the launcher.
|
||||
* Null leaves every item with the ordinary press it has always had — the rail is drawn
|
||||
* beside screens that have nowhere to put a menu.
|
||||
*/
|
||||
onUserLongPressed: (() -> Unit)? = null,
|
||||
) {
|
||||
var railHasFocus by remember { mutableStateOf(false) }
|
||||
val logoScale = remember { Animatable(0.72f) }
|
||||
val logoAlpha = remember { Animatable(0f) }
|
||||
val logoRotation = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
launch { logoScale.animateTo(1f, tween(620)) }
|
||||
launch { logoAlpha.animateTo(1f, tween(420)) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(selected) {
|
||||
logoRotation.snapTo(0f)
|
||||
logoRotation.animateTo(360f, tween(850))
|
||||
}
|
||||
LaunchedEffect(railHasFocus) {
|
||||
if (railHasFocus) {
|
||||
// Spatial focus may initially choose the first rail item (Home). Correct that
|
||||
// only as focus enters the rail; subsequent up/down movement must remain free.
|
||||
runCatching { navigationFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
val railWidth = animateDpAsState(
|
||||
targetValue = if (expanded) config.expandedWidthDp.dp else TvRailCollapsedWidth,
|
||||
animationSpec = tween(160),
|
||||
label = "navigation-rail-width",
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(TvRailCollapsedWidth)
|
||||
.fillMaxHeight()
|
||||
.zIndex(8f),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
// The parent deliberately reports only the collapsed footprint to the
|
||||
// home Row. requiredWidth lets the focused surface draw outward without
|
||||
// remeasuring gallery cards or clipping their labels to 54dp.
|
||||
// Only width may escape the collapsed 54dp footprint. Unbinding height
|
||||
// here would make fillMaxHeight lose the screen constraint and stop the
|
||||
// rail surface at its final child (the version label).
|
||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||
// Read in the measure lambda, not the modifier chain: expanding the rail
|
||||
// is the most frequent animation in the app, and reading the animated Dp
|
||||
// during composition recomposes the rail and every item in it per frame.
|
||||
.layout { measurable, constraints ->
|
||||
val width = railWidth.value.roundToPx()
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minWidth = width, maxWidth = width),
|
||||
)
|
||||
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
|
||||
}
|
||||
.fillMaxHeight()
|
||||
.background(RailSurface)
|
||||
.onFocusChanged { focusState ->
|
||||
val hasFocus = focusState.hasFocus
|
||||
if (railHasFocus != hasFocus) {
|
||||
railHasFocus = hasFocus
|
||||
onRailFocusChanged(hasFocus)
|
||||
}
|
||||
}
|
||||
.focusGroup()
|
||||
.padding(horizontal = 5.dp, vertical = 15.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.height(42.dp).padding(horizontal = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Memby's own icon rather than Emby's mark: the rail is Memby's chrome, and
|
||||
// the one place the app names itself should not be somebody else's logo.
|
||||
// It carries its own colour and is deliberately not tinted — this is the
|
||||
// app's icon, the same one the launcher on the television shows, so a
|
||||
// seasonal palette repainting it would make it a different mark.
|
||||
Image(
|
||||
painter = painterResource(R.drawable.memby_mark),
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = logoScale.value
|
||||
scaleY = logoScale.value
|
||||
alpha = logoAlpha.value
|
||||
rotationZ = logoRotation.value
|
||||
},
|
||||
)
|
||||
if (expanded) {
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Text(
|
||||
"Memby",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
config.tagline,
|
||||
color = QuietText,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// A destination with nothing behind it is worse than one fewer: the calendar
|
||||
// needs the gateway and a Sonarr, and a household with neither would otherwise
|
||||
// carry a rail item that only ever opens an apology.
|
||||
val destinations = remember(calendarEnabled, genresEnabled) {
|
||||
navigationRailItems(calendarEnabled, genresEnabled)
|
||||
}
|
||||
// Up and Down are explicit because the profile entry is an action while every
|
||||
// item beneath it changes the current destination. Leaving this to spatial
|
||||
// search allowed content behind the expanded rail to win occasionally.
|
||||
val itemFocusRequesters = remember(
|
||||
destinations,
|
||||
focusDestination,
|
||||
navigationFocusRequester,
|
||||
) {
|
||||
destinations.associateWith { destination ->
|
||||
if (destination == focusDestination) navigationFocusRequester
|
||||
else FocusRequester()
|
||||
}
|
||||
}
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val itemFocusRequester = itemFocusRequesters.getValue(destination)
|
||||
ExpandableNavigationItem(
|
||||
destination = destination,
|
||||
label = if (destination == BrowseDestination.PROFILES) {
|
||||
profileDestinationLabel(activeUsername)
|
||||
} else {
|
||||
configuredNavigationLabel(destination, config.labels)
|
||||
},
|
||||
selected = destination == selected,
|
||||
expanded = expanded,
|
||||
modifier = Modifier
|
||||
.focusRequester(itemFocusRequester)
|
||||
.focusProperties {
|
||||
up = if (index == 0) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
itemFocusRequesters.getValue(destinations[index - 1])
|
||||
}
|
||||
down = if (index == destinations.lastIndex) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
itemFocusRequesters.getValue(destinations[index + 1])
|
||||
}
|
||||
},
|
||||
onFocused = {},
|
||||
onClick = { onDestinationSelected(destination) },
|
||||
// Notifications are one level in, behind the user picker. Without a mark out
|
||||
// here nothing on the launcher would ever say there was news waiting.
|
||||
badge = if (destination == BrowseDestination.PROFILES) {
|
||||
alertBadgeLabel(alertCount)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
avatarInitials = activeUsername.takeIf {
|
||||
destination == BrowseDestination.PROFILES
|
||||
}?.let { profileInitials(it, activeProfileInitials) },
|
||||
// Only the user item. A hold anywhere else on the rail still means the
|
||||
// press it always meant, so nothing a viewer already knows changes.
|
||||
onLongClick = onUserLongPressed.takeIf {
|
||||
destination == BrowseDestination.PROFILES
|
||||
},
|
||||
longPressLabel = "User shortcuts",
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (config.showVersion) {
|
||||
Text(
|
||||
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
|
||||
color = QuietText,
|
||||
fontSize = if (expanded) 10.sp else 8.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuredNavigationLabel(
|
||||
destination: BrowseDestination,
|
||||
labels: NavigationLabels,
|
||||
): String = when (destination) {
|
||||
BrowseDestination.HOME -> labels.home
|
||||
BrowseDestination.FOR_YOU -> labels.forYou
|
||||
BrowseDestination.SEARCH -> labels.search
|
||||
BrowseDestination.MOVIES -> labels.movies
|
||||
BrowseDestination.SHOWS -> labels.tvShows
|
||||
BrowseDestination.GENRES -> labels.genres
|
||||
BrowseDestination.CALENDAR -> labels.tvCalendar
|
||||
BrowseDestination.FAVORITES -> labels.favourites
|
||||
BrowseDestination.PROFILES -> labels.user
|
||||
BrowseDestination.SETTINGS -> labels.settings
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserSwitcherOverlay(
|
||||
profiles: List<EmbyProfile>,
|
||||
activeProfileId: String?,
|
||||
onProfileSelected: (EmbyProfile) -> Unit,
|
||||
onManageProfiles: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
alertCount: Int = 0,
|
||||
onOpenAlerts: () -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
/**
|
||||
* Whether this viewer may ask the household for titles. False hides the entry entirely
|
||||
* rather than dimming it: an operator's allowlist is not something a viewer can act on,
|
||||
* so a greyed row would only ever raise a question nothing on this television can
|
||||
* answer. The data layer refuses regardless — see
|
||||
* [com.ponzischeme89.memby.data.EmbyRepository.requestsPermitted].
|
||||
*/
|
||||
showRequests: Boolean = false,
|
||||
onOpenRequests: () -> Unit = {},
|
||||
/**
|
||||
* Whether this household runs viewers. True turns the panel's list from *accounts* into
|
||||
* *people*: see [userSwitcherRows] for why the answer decides what the one list holds
|
||||
* rather than adding a second list beside it. False everywhere the question cannot be
|
||||
* asked — on the direct path there is nobody to ask — as [showRequests] is.
|
||||
*/
|
||||
showViewers: Boolean = false,
|
||||
viewers: List<MembyViewer> = emptyList(),
|
||||
activeViewerId: String = "",
|
||||
onViewerSelected: (MembyViewer) -> Unit = {},
|
||||
onAddViewer: () -> Unit = {},
|
||||
/** False at the gateway's limit: the row is removed rather than dimmed. */
|
||||
canAddViewer: Boolean = false,
|
||||
onManageViewers: () -> Unit = {},
|
||||
) {
|
||||
val menuItems = userSwitcherMenuItems(showRequests, showViewers)
|
||||
val actionCount = menuItems.size
|
||||
val rows = remember(
|
||||
profiles,
|
||||
viewers,
|
||||
activeProfileId,
|
||||
activeViewerId,
|
||||
showViewers,
|
||||
canAddViewer,
|
||||
) {
|
||||
userSwitcherRows(
|
||||
profiles = profiles,
|
||||
viewers = viewers,
|
||||
activeProfileId = activeProfileId,
|
||||
activeViewerId = activeViewerId,
|
||||
showViewers = showViewers,
|
||||
canAddViewer = canAddViewer,
|
||||
)
|
||||
}
|
||||
// Re-keyed on the action count as well as the rows: a permission arriving on a poll
|
||||
// while this menu is open changes how many rows there are, and a requester list of the
|
||||
// old length would leave the new row unfocusable.
|
||||
val focusRequesters = remember(rows, menuItems) {
|
||||
List(rows.size + actionCount) { FocusRequester() }
|
||||
}
|
||||
val profileListState = remember(rows) { LazyListState() }
|
||||
val focusScope = rememberCoroutineScope()
|
||||
var profileFocusJob by remember(rows) { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var focusedIndex by remember(rows) { mutableIntStateOf(userSwitcherRowFocusIndex(rows)) }
|
||||
LaunchedEffect(rows) {
|
||||
profileFocusJob?.cancel()
|
||||
focusedIndex = userSwitcherRowFocusIndex(rows)
|
||||
if (rows.isNotEmpty()) {
|
||||
profileListState.scrollToItem(focusedIndex)
|
||||
}
|
||||
// Let a lazily composed off-screen row attach its FocusRequester first.
|
||||
delay(16.milliseconds)
|
||||
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(20f)
|
||||
.background(Color.Black.copy(alpha = 0.56f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 52.dp)
|
||||
.width(292.dp)
|
||||
// 460dp of a 540dp television, so the panel still reads as a panel over the
|
||||
// launcher rather than as a screen. It went up from 400dp when the list
|
||||
// became the *people*: an account list is one or two rows and a household
|
||||
// is up to eight, and the actions below were being clipped off the bottom
|
||||
// — including the row that manages the very list doing the clipping.
|
||||
.heightIn(max = 460.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||
.background(MembySurface)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
val direction = if (event.key == Key.DirectionUp) {
|
||||
UserSwitcherDirection.UP
|
||||
} else {
|
||||
UserSwitcherDirection.DOWN
|
||||
}
|
||||
val next = userSwitcherNextIndex(
|
||||
currentIndex = focusedIndex,
|
||||
profileCount = rows.size,
|
||||
direction = direction,
|
||||
actionCount = actionCount,
|
||||
)
|
||||
focusedIndex = next
|
||||
profileFocusJob?.cancel()
|
||||
if (next < rows.size) {
|
||||
profileFocusJob = focusScope.launch {
|
||||
profileListState.scrollToItem(next)
|
||||
delay(16.milliseconds)
|
||||
runCatching { focusRequesters[next].requestFocus() }
|
||||
}
|
||||
} else {
|
||||
runCatching { focusRequesters[next].requestFocus() }
|
||||
}
|
||||
true
|
||||
}
|
||||
Key.DirectionLeft, Key.Back -> {
|
||||
onDismiss()
|
||||
true
|
||||
}
|
||||
// Keep focus inside this modal instead of allowing spatial focus
|
||||
// search to land on a dimmed card behind it.
|
||||
Key.DirectionRight -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(
|
||||
// The panel is named for what its list holds. With viewers in play that is
|
||||
// people, and the accounts are one row below — so this is the only place
|
||||
// the question is asked, rather than a heading and a row below it both
|
||||
// claiming to answer it.
|
||||
if (showViewers) "Who’s watching?" else "Switch user",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
||||
)
|
||||
Text(
|
||||
if (showViewers) {
|
||||
"Everyone keeps their own Continue Watching."
|
||||
} else {
|
||||
"Choose who’s watching"
|
||||
},
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
state = profileListState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Weighted, and therefore measured from what the pinned actions left
|
||||
// over rather than from a figure typed in here. A fixed cap has to be
|
||||
// re-derived by hand every time a row is added below it, and the one
|
||||
// that was here had stopped being right the moment the list could hold
|
||||
// eight people instead of two accounts. `fill = false` so a household
|
||||
// of one still gets a panel the size of its contents.
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
itemsIndexed(items = rows, key = { _, row -> userSwitcherRowKey(row) }) { index, row ->
|
||||
val rowModifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
||||
when (row) {
|
||||
is UserSwitcherRow.Account -> UserSwitcherPersonItem(
|
||||
name = row.profile.username,
|
||||
initials = profileInitials(
|
||||
row.profile.username,
|
||||
row.profile.profileInitials,
|
||||
),
|
||||
current = row.active,
|
||||
currentDescription = "current user",
|
||||
modifier = rowModifier,
|
||||
onClick = { onProfileSelected(row.profile) },
|
||||
)
|
||||
is UserSwitcherRow.Viewer -> UserSwitcherPersonItem(
|
||||
name = row.viewer.name,
|
||||
initials = row.viewer.initials,
|
||||
current = row.active,
|
||||
currentDescription = "currently watching",
|
||||
modifier = rowModifier,
|
||||
onClick = { onViewerSelected(row.viewer) },
|
||||
)
|
||||
// Inside the list rather than below the pinned actions, because it
|
||||
// belongs to the question the list is asking — and it is the only
|
||||
// way an account with nobody added yet ever gains a first person.
|
||||
UserSwitcherRow.AddViewer -> UserSwitcherAction(
|
||||
label = "Add viewer",
|
||||
icon = MembyIcon.Add.mark,
|
||||
modifier = rowModifier,
|
||||
onClick = onAddViewer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.07f)),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Every row's index comes from its position in [menuItems] rather than being
|
||||
// written out here. The four hand-computed offsets this replaced were what made
|
||||
// adding a fifth conditional row unsafe.
|
||||
menuItems.forEachIndexed { offset, item ->
|
||||
val index = rows.size + offset
|
||||
val modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index }
|
||||
when (item) {
|
||||
// The accounts, now that the list above holds people instead. It sits
|
||||
// above the rest because it changes whose menu this is: the
|
||||
// notifications and requests below belong to whatever it selects. The
|
||||
// screen it opens is also where an account is added and removed, which
|
||||
// is why no separate "Manage accounts" row sits beside it.
|
||||
UserSwitcherMenuItem.SWITCH_ACCOUNT -> UserSwitcherAction(
|
||||
label = switchAccountLabel(profiles, activeProfileId),
|
||||
icon = MembyIcon.Person.mark,
|
||||
modifier = modifier,
|
||||
onClick = onManageProfiles,
|
||||
)
|
||||
// Notifications live here rather than on the launcher: these belong to
|
||||
// a person and follow them between televisions, so the menu that
|
||||
// already answers "who is watching" is where somebody looks for their
|
||||
// own news. The badge is what replaces the bell that used to sit in the
|
||||
// corner of Home.
|
||||
UserSwitcherMenuItem.NOTIFICATIONS -> UserSwitcherAction(
|
||||
label = "Notifications",
|
||||
icon = MembyIcon.Notification.mark,
|
||||
badge = alertBadgeLabel(alertCount),
|
||||
modifier = modifier,
|
||||
onClick = onOpenAlerts,
|
||||
)
|
||||
// Requests sits beside Notifications because both are personal activity.
|
||||
UserSwitcherMenuItem.REQUESTS -> UserSwitcherAction(
|
||||
label = "Requests",
|
||||
icon = MembyIcon.PlaylistAdd.mark,
|
||||
modifier = modifier,
|
||||
onClick = onOpenRequests,
|
||||
)
|
||||
UserSwitcherMenuItem.SETTINGS -> UserSwitcherAction(
|
||||
label = "Settings",
|
||||
icon = MembyIcon.Settings.mark,
|
||||
modifier = modifier,
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
// Renaming and removing the people in the list above. Choosing one of
|
||||
// them is a press in that list; this is the rarer, editing half, which
|
||||
// is why it is at the bottom rather than competing with the names.
|
||||
UserSwitcherMenuItem.MANAGE_VIEWERS -> UserSwitcherAction(
|
||||
label = "Manage viewers",
|
||||
icon = MembyIcon.Grid.mark,
|
||||
modifier = modifier,
|
||||
onClick = onManageViewers,
|
||||
)
|
||||
// The same row where viewers are not in play: there the list above is
|
||||
// the accounts, and this is what edits it.
|
||||
UserSwitcherMenuItem.MANAGE_USERS -> UserSwitcherAction(
|
||||
label = "Manage users",
|
||||
icon = MembyIcon.Grid.mark,
|
||||
modifier = modifier,
|
||||
onClick = onManageProfiles,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One name in the panel's list, whether that list holds accounts or people.
|
||||
*
|
||||
* It is one composable rather than two because the two lists are never on screen together:
|
||||
* the row a household reads every evening should look the same whichever of the two
|
||||
* questions their gateway has them answering. [currentDescription] is the only thing that
|
||||
* differs, and it differs because "current user" and "currently watching" are genuinely
|
||||
* different claims.
|
||||
*/
|
||||
@Composable
|
||||
private fun UserSwitcherPersonItem(
|
||||
name: String,
|
||||
initials: String,
|
||||
current: Boolean,
|
||||
currentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White.copy(alpha = 0.11f)
|
||||
current -> EmbyGreen.copy(alpha = 0.07f)
|
||||
else -> Color.Transparent
|
||||
},
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics {
|
||||
contentDescription = if (current) "$name, $currentDescription" else name
|
||||
}
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(26.dp)
|
||||
.background(if (current) EmbyGreen else MembyControlSurfaceRaised, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
initials,
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
name,
|
||||
color = if (focused || current) Color.White else MutedText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (current) {
|
||||
Icon(
|
||||
MembyIcon.CheckCircle.mark,
|
||||
contentDescription = null,
|
||||
tint = EmbyGreen,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserSwitcherAction(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
badge: String? = null,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics {
|
||||
contentDescription = badge?.let { "$label, $it waiting" } ?: label
|
||||
}
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (focused) Color.White else QuietText,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) Color.White else QuietText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (badge != null) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
badge,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.background(AlertBadgeRed, CircleShape)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The one red a waiting-alert badge is drawn in, on the rail and in the user menu alike. */
|
||||
private val AlertBadgeRed = Color(0xFFE04747)
|
||||
|
||||
@Composable
|
||||
fun ExpandableNavigationItem(
|
||||
destination: BrowseDestination,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = destination.label,
|
||||
badge: String? = null,
|
||||
avatarInitials: String? = null,
|
||||
/**
|
||||
* A hold rather than a press. Only the user item offers one today — it is where the
|
||||
* shortcut that clears this viewer's notifications lives — so a rail item with nothing
|
||||
* behind a hold keeps the ordinary press it always had, and this stays null.
|
||||
*/
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
longPressLabel: String = "Quick actions",
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
// Not `by`: both colours are read in the draw phase / at the point of use, so the
|
||||
// 85ms focus transition repaints this row rather than recomposing it.
|
||||
val background = animateColorAsState(
|
||||
targetValue = when {
|
||||
focused -> Color.White.copy(alpha = 0.13f)
|
||||
selected -> EmbyGreen.copy(alpha = 0.10f)
|
||||
else -> Color.Transparent
|
||||
},
|
||||
animationSpec = tween(85),
|
||||
label = "navigation-item-background",
|
||||
)
|
||||
val foreground = animateColorAsState(
|
||||
targetValue = when {
|
||||
focused -> Color.White
|
||||
selected -> EmbyGreen
|
||||
else -> MutedText
|
||||
},
|
||||
animationSpec = tween(85),
|
||||
label = "navigation-item-foreground",
|
||||
)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.drawBehind { drawRect(background.value) }
|
||||
.remoteLongPress(onClick = onClick, onLongClick = onLongClick)
|
||||
.semantics {
|
||||
contentDescription = badge?.let { "$label, $it waiting" } ?: label
|
||||
if (onLongClick != null) {
|
||||
onLongClick(longPressLabel) {
|
||||
onLongClick.invoke()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
|
||||
if (avatarInitials != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(25.dp)
|
||||
.background(
|
||||
if (selected) EmbyGreen else foreground.value.copy(alpha = 0.22f),
|
||||
CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
avatarInitials,
|
||||
color = if (selected) Color.White else foreground.value,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(destination.icon.mark, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
|
||||
}
|
||||
if (selected) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.width(2.dp)
|
||||
.height(18.dp)
|
||||
.background(EmbyGreen, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
}
|
||||
// Over the icon rather than after the label: the rail spends most of its life
|
||||
// collapsed to 54dp, where there is no label to sit beside.
|
||||
if (badge != null) {
|
||||
Text(
|
||||
badge,
|
||||
color = Color.White,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.background(AlertBadgeRed, CircleShape)
|
||||
.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
Text(
|
||||
label,
|
||||
color = foreground.value,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin-defined initials win; otherwise derive two readable characters from the name. */
|
||||
internal fun profileInitials(username: String, override: String = ""): String {
|
||||
override.trim().takeIf(String::isNotEmpty)?.let { return it.take(2).uppercase() }
|
||||
val trimmed = username.trim()
|
||||
if (trimmed.isEmpty()) return "?"
|
||||
val words = trimmed.split(Regex("\\s+")).filter(String::isNotBlank)
|
||||
if (words.size > 1) {
|
||||
return "${words.first().first()}${words.last().first()}".uppercase()
|
||||
}
|
||||
val capitals = trimmed.filter(Char::isUpperCase)
|
||||
return when {
|
||||
capitals.length >= 2 -> capitals.take(2).uppercase()
|
||||
trimmed.length >= 2 -> trimmed.take(2).uppercase()
|
||||
else -> trimmed.uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile rail names the person currently signed in, not the action behind the row. */
|
||||
internal fun profileDestinationLabel(username: String): String =
|
||||
username.trim().ifEmpty { "User" }
|
||||
@@ -0,0 +1,390 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// The launcher's names for the shared tokens. Both surfaces read from one palette now
|
||||
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
|
||||
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||
// to be loaded when this class initialised.
|
||||
private val EmbyGreen: Color get() = MembyAccent
|
||||
|
||||
// The rail sits over the launcher rather than beside it, so it is the surface at the
|
||||
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
|
||||
// the reasons a theme change left most of the screen looking untouched.
|
||||
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
/**
|
||||
* One entry in the long-press menu.
|
||||
*
|
||||
* The menu is built as a list rather than as a fixed sequence of blocks with hand-written
|
||||
* indices, because what belongs on it depends on the card: a film Radarr is tracking has no
|
||||
* Emby record to favourite or mark watched, and it has a trailer where an ordinary card
|
||||
* does not. Deriving the focus indices from the list is what keeps that from being four
|
||||
* pieces of arithmetic to hold in step.
|
||||
*/
|
||||
private data class QuickAction(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MediaQuickActionsOverlay(
|
||||
item: BaseItem,
|
||||
onOpenDetails: (BaseItem) -> Unit,
|
||||
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
||||
onRemoveFromContinueWatching: (() -> Unit)? = null,
|
||||
/**
|
||||
* Offered from the row for a film that is not in the library yet, which is the one card
|
||||
* whose trailer is the only thing there is to play. Pressing the card itself still opens
|
||||
* its page — a long press is where a shortcut belongs, not where the ordinary action is
|
||||
* replaced by it.
|
||||
*/
|
||||
onPlayTrailer: (() -> Unit)? = null,
|
||||
rowTitle: String? = null,
|
||||
rowPinned: Boolean = false,
|
||||
onToggleRowPinned: (() -> Unit)? = null,
|
||||
onHideRow: (() -> Unit)? = null,
|
||||
onMoveRow: ((Int) -> Unit)? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val hasRowActions = rowTitle != null &&
|
||||
onToggleRowPinned != null &&
|
||||
onHideRow != null &&
|
||||
onMoveRow != null
|
||||
// Library state belongs to an Emby item. A card standing for a film the household does
|
||||
// not hold has none, and a "Mark watched" that answers 404 is worse than no entry.
|
||||
val hasLibraryActions = !item.isRadarrOnly
|
||||
val itemActions = buildList {
|
||||
add(QuickAction("View details", MembyIcon.Info.mark) { onOpenDetails(item) })
|
||||
onPlayTrailer?.let { play ->
|
||||
add(QuickAction("Play trailer", MembyIcon.Movie.mark) { play() })
|
||||
}
|
||||
if (hasLibraryActions) {
|
||||
add(
|
||||
QuickAction(
|
||||
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
|
||||
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
|
||||
) {
|
||||
onSetFavorite(item, !item.isFavorite)
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
add(
|
||||
QuickAction(
|
||||
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
|
||||
icon = MembyIcon.CheckCircle.mark,
|
||||
) {
|
||||
onSetPlayed(item, item.userData?.played != true)
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
}
|
||||
onRemoveFromContinueWatching?.let {
|
||||
add(QuickAction("Remove from Continue Watching", MembyIcon.PlaylistRemove.mark, it))
|
||||
}
|
||||
}
|
||||
val rowActions = if (!hasRowActions) {
|
||||
emptyList()
|
||||
} else {
|
||||
val onTogglePinned = requireNotNull(onToggleRowPinned)
|
||||
val onMove = requireNotNull(onMoveRow)
|
||||
val onHide = requireNotNull(onHideRow)
|
||||
listOf(
|
||||
QuickAction(
|
||||
label = if (rowPinned) "Unpin row" else "Pin row to top",
|
||||
icon = MembyIcon.Pin.mark,
|
||||
onClick = onTogglePinned,
|
||||
),
|
||||
QuickAction("Move row up", MembyIcon.ArrowUp.mark) { onMove(-1) },
|
||||
QuickAction("Move row down", MembyIcon.ArrowDown.mark) { onMove(1) },
|
||||
QuickAction("Hide this row", MembyIcon.HideWatched.mark, onHide),
|
||||
)
|
||||
}
|
||||
val actions = itemActions + rowActions +
|
||||
QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose)
|
||||
val actionCount = actions.size
|
||||
val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } }
|
||||
var focusedIndex by remember(item.id) { mutableIntStateOf(0) }
|
||||
// The menu can appear while OK is still physically held. Until that opening press
|
||||
// is released, consume all activation events so it cannot trigger the first action.
|
||||
var openingPressReleased by remember(item.id) { mutableStateOf(false) }
|
||||
LaunchedEffect(item.id) {
|
||||
delay(16.milliseconds)
|
||||
runCatching { focusRequesters.first().requestFocus() }
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(20f)
|
||||
.background(Color.Black.copy(alpha = 0.58f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 32.dp)
|
||||
.width(352.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||
.background(MembySurface)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
||||
.onPreviewKeyEvent { event ->
|
||||
val native = event.nativeKeyEvent
|
||||
val activationKey =
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||
if (activationKey && !openingPressReleased) {
|
||||
if (native.action == android.view.KeyEvent.ACTION_UP) {
|
||||
openingPressReleased = true
|
||||
}
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
when (event.key) {
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
val direction = if (event.key == Key.DirectionUp) {
|
||||
QuickActionDirection.UP
|
||||
} else {
|
||||
QuickActionDirection.DOWN
|
||||
}
|
||||
focusedIndex = quickActionNextIndex(
|
||||
currentIndex = focusedIndex,
|
||||
actionCount = actionCount,
|
||||
direction = direction,
|
||||
)
|
||||
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
||||
true
|
||||
}
|
||||
Key.DirectionLeft, Key.Back -> {
|
||||
onClose()
|
||||
true
|
||||
}
|
||||
Key.DirectionRight -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"Quick actions",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 5.dp),
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
color = QuietText,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
actions.forEachIndexed { index, action ->
|
||||
// The row actions are about the shelf rather than about the title, and
|
||||
// Close is about neither, so each is introduced by its own rule.
|
||||
when {
|
||||
rowActions.isNotEmpty() && index == itemActions.size -> {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
QuickActionDivider()
|
||||
Text(
|
||||
rowTitle.orEmpty(),
|
||||
color = QuietText,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
|
||||
)
|
||||
}
|
||||
index == actionCount - 1 -> {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
QuickActionDivider()
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
index > 0 -> Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
QuickActionMenuItem(
|
||||
label = action.label,
|
||||
icon = action.icon,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
||||
onClick = action.onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.07f)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionMenuItem(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(42.dp)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (focused) Color.White else QuietText,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) Color.White else MutedText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
||||
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
|
||||
|
||||
data class HomeBrowseRow(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val items: List<BaseItem>,
|
||||
val kind: MediaRowKind,
|
||||
val loading: Boolean = false,
|
||||
val emptyMessage: String,
|
||||
val showSecondaryMetadata: Boolean = true,
|
||||
val showWatchedEpisodeCount: Boolean = false,
|
||||
)
|
||||
|
||||
private data class HomeRowVisual(
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
row.id == "continue" -> HomeRowVisual(
|
||||
MembyIcon.PlayCircle.mark,
|
||||
)
|
||||
row.id == "continue-shows" -> HomeRowVisual(
|
||||
MembyIcon.PlayCircle.mark,
|
||||
)
|
||||
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
|
||||
MembyIcon.Favourite.mark,
|
||||
)
|
||||
row.id == "latest-movies" -> HomeRowVisual(
|
||||
MembyIcon.Movie.mark,
|
||||
)
|
||||
row.id == "sonarr-airing-today" -> HomeRowVisual(
|
||||
MembyIcon.Calendar.mark,
|
||||
)
|
||||
row.id == "curated:apple-tv" -> HomeRowVisual(
|
||||
MembyIcon.LiveTv.mark,
|
||||
)
|
||||
row.id == "curated:drama-shows" -> HomeRowVisual(
|
||||
MembyIcon.Drama.mark,
|
||||
)
|
||||
row.id == "curated:comedy-shows" -> HomeRowVisual(
|
||||
MembyIcon.Happy.mark,
|
||||
)
|
||||
row.id.startsWith("similar:") -> HomeRowVisual(
|
||||
MembyIcon.Sparkle.mark,
|
||||
)
|
||||
row.id == "recommended" -> HomeRowVisual(
|
||||
MembyIcon.Recommend.mark,
|
||||
)
|
||||
row.id.startsWith("for-you:") -> HomeRowVisual(
|
||||
MembyIcon.Sparkle.mark,
|
||||
)
|
||||
else -> HomeRowVisual(
|
||||
MembyIcon.VideoLibrary.mark,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The 28dp icon chip every home row header leads with, and the gap after it.
|
||||
*
|
||||
* Extracted because a header that skipped the chip started its title 38dp further left than
|
||||
* the rows above and below it. The launcher's row titles are read as one column, and one
|
||||
* that steps sideways breaks the column.
|
||||
*/
|
||||
internal val HomeRowHeaderIconGap = 10.dp
|
||||
|
||||
/** Vertical gap between a row's header and its cards. One value for every row. */
|
||||
internal val HomeRowHeaderSpacing = 10.dp
|
||||
|
||||
private val HomeRowCardsTopPadding = 12.dp
|
||||
private val HomeRowCardsBottomPadding = 32.dp
|
||||
|
||||
@Composable
|
||||
internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
internal fun MediaRow(
|
||||
modifier: Modifier = Modifier,
|
||||
row: HomeBrowseRow,
|
||||
availableWidth: Dp,
|
||||
contentEntryFocusRequester: FocusRequester?,
|
||||
heroEntryFocusRequester: FocusRequester? = null,
|
||||
returnFocusItemId: String?,
|
||||
returnFocusRequester: FocusRequester,
|
||||
verticalFocusRequest: RowFocusRequest?,
|
||||
onVerticalFocusRequestConsumed: (Int) -> Unit,
|
||||
onMoveVertical: (itemIndex: Int, direction: RowFocusDirection) -> Boolean,
|
||||
onContentFocused: () -> Unit,
|
||||
onItemFocused: (item: BaseItem, itemIndex: Int) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onItemLongPressed: (BaseItem) -> Unit,
|
||||
density: String = "standard",
|
||||
artworkStyle: String = "automatic",
|
||||
horizontalState: LazyListState? = null,
|
||||
) {
|
||||
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||
val rowState = horizontalState ?: savedRowState
|
||||
val verticalEntryFocusRequester = remember { FocusRequester() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val requestedEntryIndex = verticalFocusRequest
|
||||
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||
?.itemIndex
|
||||
?.coerceIn(0, row.items.lastIndex)
|
||||
val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(
|
||||
onVerticalFocusRequestConsumed,
|
||||
)
|
||||
LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
|
||||
val entryIndex = requestedEntryIndex ?: return@LaunchedEffect
|
||||
val request = verticalFocusRequest
|
||||
?.takeIf { it.rowId == row.id }
|
||||
?: return@LaunchedEffect
|
||||
rowState.scrollToItem(entryIndex)
|
||||
// LazyRow applies scrollToItem during layout. Wait for the requested card's focus
|
||||
// node to attach before transferring focus; retrying covers slower TV frames. Six
|
||||
// attempts rather than three because giving up here is a press that visibly does
|
||||
// nothing, and the shelf this arrives at is commonly one being composed for the
|
||||
// first time — the frame budget on a Chromecast is not the one a warm row gets.
|
||||
repeat(6) {
|
||||
delay(16.milliseconds)
|
||||
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
|
||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||
}
|
||||
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
|
||||
// only the button's enabled/disabled flip recomposes the row header.
|
||||
val canScrollBack by remember(rowState) {
|
||||
derivedStateOf { rowState.firstVisibleItemIndex > 0 }
|
||||
}
|
||||
val canScrollForward by remember(rowState) {
|
||||
derivedStateOf { rowState.canScrollForward }
|
||||
}
|
||||
val pageSize = if (
|
||||
row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
|
||||
) 6 else 4
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = HomeContentHorizontalInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val visual = homeRowVisual(row)
|
||||
HomeRowHeaderIcon(visual.icon)
|
||||
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
||||
Text(
|
||||
row.title,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (row.items.isNotEmpty()) {
|
||||
GalleryJumpButton(
|
||||
forward = false,
|
||||
enabled = canScrollBack,
|
||||
onClick = {
|
||||
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
|
||||
scope.launch { rowState.scrollToItem(target) }
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GalleryJumpButton(
|
||||
forward = true,
|
||||
enabled = canScrollForward,
|
||||
onClick = {
|
||||
val target = (rowState.firstVisibleItemIndex + pageSize)
|
||||
.coerceAtMost(row.items.lastIndex)
|
||||
scope.launch { rowState.scrollToItem(target) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
row.items.isEmpty() && row.loading -> {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(
|
||||
start = HomeContentHorizontalInset,
|
||||
end = HomeContentHorizontalInset,
|
||||
top = HomeRowCardsTopPadding,
|
||||
bottom = HomeRowCardsBottomPadding,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(5) {
|
||||
TvLoadingPlaceholder(
|
||||
portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS),
|
||||
availableWidth = availableWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
row.items.isEmpty() -> {
|
||||
Text(
|
||||
row.emptyMessage,
|
||||
color = QuietText,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = HomeContentHorizontalInset,
|
||||
vertical = 18.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
contentPadding = PaddingValues(
|
||||
start = HomeContentHorizontalInset,
|
||||
end = HomeContentHorizontalInset,
|
||||
top = HomeRowCardsTopPadding,
|
||||
bottom = HomeRowCardsBottomPadding,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
// focusRestorer pins a lazy item. Replacing a For You result set
|
||||
// can dispose that item during a focus transfer and make Compose
|
||||
// release the same pin twice. LazyListState already preserves the
|
||||
// row position; native TV spatial search safely handles row changes.
|
||||
modifier = Modifier.fillMaxWidth().focusGroup(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
row.items,
|
||||
key = { _, item -> item.id },
|
||||
contentType = { _, item -> if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" },
|
||||
) { index, item ->
|
||||
var cardModifier: Modifier = Modifier
|
||||
if (index == 0) {
|
||||
if (contentEntryFocusRequester != null) {
|
||||
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
|
||||
}
|
||||
// Where Down out of the home hero lands. It is a second
|
||||
// requester rather than the entry one because while the hero is
|
||||
// up, that one belongs to the hero.
|
||||
if (heroEntryFocusRequester != null) {
|
||||
cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
|
||||
}
|
||||
}
|
||||
if (item.id == returnFocusItemId) {
|
||||
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
if (index == requestedEntryIndex) {
|
||||
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
|
||||
}
|
||||
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP)
|
||||
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val focused: () -> Unit = {
|
||||
onContentFocused()
|
||||
// LazyRow already knows the semantic position. Passing it on
|
||||
// avoids searching the row again on every D-pad focus move.
|
||||
onItemFocused(item, index)
|
||||
}
|
||||
val format = cardFormat(row.kind, item, artworkStyle)
|
||||
if (row.kind == MediaRowKind.CONTINUE) {
|
||||
ContinueWatchingCard(
|
||||
item = item,
|
||||
availableWidth = availableWidth,
|
||||
portraitArtwork = format == MediaCardFormat.PORTRAIT,
|
||||
onFocused = focused,
|
||||
onClick = { onItemSelected(item) },
|
||||
onLongClick = { onItemLongPressed(item) },
|
||||
modifier = cardModifier,
|
||||
density = density,
|
||||
)
|
||||
} else when (format) {
|
||||
MediaCardFormat.PORTRAIT -> PosterCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
)
|
||||
MediaCardFormat.LANDSCAPE -> {
|
||||
LandscapeCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryJumpButton(
|
||||
forward: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { if (enabled) onClick() },
|
||||
onLongClick = null,
|
||||
contentDescription = if (forward) "Next page" else "Previous page",
|
||||
// These mirror remote left/right paging for pointer users. Keeping them out of
|
||||
// the focus graph prevents Up from a card landing in a tiny header control.
|
||||
modifier = Modifier
|
||||
.width(42.dp)
|
||||
.height(36.dp)
|
||||
.focusProperties { canFocus = false },
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White.copy(alpha = 0.16f)
|
||||
enabled -> Color.White.copy(alpha = 0.08f)
|
||||
else -> Color.White.copy(alpha = 0.03f)
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (forward) MembyIcon.ChevronRight.mark else MembyIcon.ChevronLeft.mark,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f),
|
||||
modifier = Modifier.size(23.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
|
||||
|
||||
private val MediaRowHorizontalInset = HomeContentHorizontalInset
|
||||
private val MediaRowCardSpacing = 16.dp
|
||||
|
||||
/**
|
||||
* Sizes a shelf to an integer number of complete cards.
|
||||
*
|
||||
* Work from the actual row width, including the launcher inset, so the first card, the
|
||||
* heading and the metadata panel stay on one column while the opposite inset still leaves
|
||||
* a deliberate sliver of the next card visible as a browse cue.
|
||||
*/
|
||||
internal fun responsiveRowCardWidth(
|
||||
availableWidth: Dp,
|
||||
preferredCardsAcross: Int,
|
||||
minWidth: Dp,
|
||||
maxWidth: Dp,
|
||||
horizontalInset: Dp = MediaRowHorizontalInset,
|
||||
spacing: Dp = MediaRowCardSpacing,
|
||||
): Dp {
|
||||
val usableWidth = (availableWidth - horizontalInset * 2).coerceAtLeast(1.dp)
|
||||
val cardsThatFitMinimum =
|
||||
((usableWidth + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
|
||||
var cardCount = preferredCardsAcross.coerceIn(1, cardsThatFitMinimum)
|
||||
|
||||
// Very wide viewports should gain another complete card instead of a large dead area.
|
||||
while (cardCount < cardsThatFitMinimum) {
|
||||
val candidate = (usableWidth - spacing * (cardCount - 1)) / cardCount
|
||||
if (candidate <= maxWidth) break
|
||||
cardCount += 1
|
||||
}
|
||||
|
||||
return ((usableWidth - spacing * (cardCount - 1)) / cardCount)
|
||||
.coerceAtMost(maxWidth)
|
||||
.coerceAtLeast(1.dp)
|
||||
}
|
||||
|
||||
private fun cardFormat(
|
||||
kind: MediaRowKind,
|
||||
item: BaseItem,
|
||||
artworkStyle: String = "automatic",
|
||||
): MediaCardFormat =
|
||||
when (artworkStyle) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when {
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user