This commit is contained in:
ponzischeme89
2026-08-21 15:24:22 +12:00
parent 4c48ef8b19
commit 4439277c86
16 changed files with 4277 additions and 2633 deletions
+1 -1
View File
@@ -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) },
)
}
@@ -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) "Whos 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 whos 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
}
}