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