From 4439277c8604b526ec9302e650d326a3873326b0 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 21 Aug 2026 15:24:22 +1200 Subject: [PATCH] 0.2.91 --- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 2630 ----------------- .../com/ponzischeme89/memby/ui/HomeScreen.kt | 3 +- .../ponzischeme89/memby/ui/MetadataHero.kt | 157 + .../ui/components/FocusScaleContainer.kt | 167 ++ .../components/media/ContinueWatchingCard.kt | 167 ++ .../ui/components/media/LandscapeCard.kt | 145 + .../memby/ui/components/media/MediaBadges.kt | 221 ++ .../ui/components/media/MediaCardMetadata.kt | 274 ++ .../ui/components/media/MediaCardSizing.kt | 156 + .../memby/ui/components/media/MediaLoading.kt | 204 ++ .../memby/ui/components/media/PosterCard.kt | 469 +++ .../memby/ui/home/HomeMetadataPanel.kt | 446 +++ .../memby/ui/home/HomeNavigation.kt | 957 ++++++ .../memby/ui/home/HomeQuickActions.kt | 390 +++ .../ponzischeme89/memby/ui/home/HomeRow.kt | 522 ++++ 16 files changed, 4277 insertions(+), 2633 deletions(-) delete mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardSizing.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaLoading.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3dc1f47..9a38c75 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,7 +64,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.90" +val defaultVersionName = "0.2.91" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt deleted file mode 100644 index 356ff37..0000000 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ /dev/null @@ -1,2630 +0,0 @@ -package com.ponzischeme89.memby.ui - -import com.ponzischeme89.memby.ui.viewers.viewerMenuLabel -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.focusable -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.requiredWidth -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.layout.wrapContentHeight -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.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.data.model.EmbyPerson -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 - -// 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 TvRailContentShift = 112.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 = - listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter { - it != BrowseDestination.PROFILES && - it != BrowseDestination.SETTINGS && - (it != BrowseDestination.CALENDAR || calendarEnabled) && - (it != BrowseDestination.GENRES || genresEnabled) - } - -// 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, - 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, - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun TvNavigationRail( - config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation, - selected: BrowseDestination, - focusDestination: BrowseDestination = selected, - expanded: Boolean, - navigationFocusRequester: FocusRequester, - onRailFocusChanged: (Boolean) -> Unit, - onDestinationSelected: (BrowseDestination) -> Unit, - modifier: Modifier = Modifier, - 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. Unbounding 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, - 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 [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 = emptyList(), - activeViewerId: String = "", - activeViewerName: 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(null) } - var focusedIndex by remember(rows) { mutableStateOf(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) - 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) - 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, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - 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, - modifier: Modifier = Modifier, - badge: String? = null, - onClick: () -> Unit, -) { - 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, - label: String = destination.label, - selected: Boolean, - expanded: Boolean, - onFocused: () -> Unit, - onClick: () -> Unit, - modifier: Modifier = Modifier, - 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" } - -@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_MS) - 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) - } - } - } - } -} - -/** - * The quiet surface between the focused title details and the scrolling shelves. - * - * The backdrop remains visible through the tint, while the fade at the lower edge keeps - * this integrated with the rows rather than turning it into an opaque panel. - */ -@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.68f), - 0.72f to MembySurface.copy(alpha = 0.46f), - 1f to MembySurface.copy(alpha = 0.16f), - ), - ), - ) { - MediaMetadataPanel( - item = item, - loading = loading, - sectionLabel = sectionLabel, - modifier = Modifier - .fillMaxSize() - .padding( - start = HomeContentHorizontalInset, - end = HomeContentHorizontalInset, - top = 28.dp, - bottom = 18.dp, - ), - ) - } -} - -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), - ) -} - -/** - * 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 { - listOf( - QuickAction( - label = if (rowPinned) "Unpin row" else "Pin row to top", - icon = MembyIcon.Pin.mark, - onClick = onToggleRowPinned!!, - ), - QuickAction("Move row up", MembyIcon.ArrowUp.mark) { onMoveRow!!(-1) }, - QuickAction("Move row down", MembyIcon.ArrowDown.mark) { onMoveRow!!(1) }, - QuickAction("Hide this row", MembyIcon.HideWatched.mark, onHideRow!!), - ) - } - 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) { mutableStateOf(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) - 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, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - 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, - ) - } -} - -@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) - } - } -} - -/** - * 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), - ) - } -} - -/** - * 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? = 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 -} - -@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), - ) -} - -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 { - 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() -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) -@Composable -internal fun MediaRow( - row: HomeBrowseRow, - availableWidth: Dp, - navigationFocusRequester: FocusRequester, - 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, - modifier: Modifier = Modifier, -) { - 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 request = verticalFocusRequest - ?.takeIf { it.rowId == row.id && requestedEntryIndex != null } - ?: return@LaunchedEffect - rowState.scrollToItem(requestedEntryIndex!!) - // 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) { - kotlinx.coroutines.delay(16L) - 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 -> PortraitMediaCard( - item, availableWidth, row.showSecondaryMetadata, focused, - { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, - density, row.showWatchedEpisodeCount, - ) - MediaCardFormat.LANDSCAPE -> { - LandscapeMediaCard( - 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 - artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE - kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE - item.isEpisode -> MediaCardFormat.LANDSCAPE - else -> MediaCardFormat.PORTRAIT -} - -@Composable -fun PortraitMediaCard( - 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. - * - * [PortraitMediaCard] 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 -fun LandscapeMediaCard( - 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, - ) -} - -@Composable -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, - ) -} - -@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)) - } - // No ratings strip under a poster. The scores are on the detail page the card - // opens and in the metadata panel beside the focused card; a third copy under - // every poster in every row cost a line of height on cards that are already - // two lines of text tall. - } - } -} - -@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), - ) - } -} - -@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), - ) -} - -@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) }, - ) -} - -@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, - ), - ), - ), - ) -} - -private const val BACKDROP_SETTLE_DELAY_MS = 240L - -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) -} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index a7e00b5..2873940 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -1512,9 +1512,9 @@ internal fun HomeScreen( ) } MediaRow( + modifier = Modifier, row = row, availableWidth = contentWidth, - navigationFocusRequester = navigationFocusRequester, contentEntryFocusRequester = contentFocusRequester.takeIf { !hasContextualHero && (selectedDestination != BrowseDestination.SHOWS || @@ -1773,7 +1773,6 @@ internal fun HomeScreen( ), viewers = viewers, activeViewerId = settings.activeViewerId, - activeViewerName = settings.activeViewerName, // Selecting a person is the panel's own press now rather than a screen // reached from it, which is the whole point: this is the thing a household // changes nightly. Everything on the launcher belongs to the outgoing diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt new file mode 100644 index 0000000..bf3f9e8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MetadataHero.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt new file mode 100644 index 0000000..57e7b92 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/FocusScaleContainer.kt @@ -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) }, + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt new file mode 100644 index 0000000..bfda9b8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt new file mode 100644 index 0000000..5e005a1 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt @@ -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, + ) +} + diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt new file mode 100644 index 0000000..187888d --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt new file mode 100644 index 0000000..b0d6c43 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt @@ -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? = 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 { + 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) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardSizing.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardSizing.kt new file mode 100644 index 0000000..e1952be --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardSizing.kt @@ -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) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaLoading.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaLoading.kt new file mode 100644 index 0000000..3eae479 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaLoading.kt @@ -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, + ), + ), + ), + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt new file mode 100644 index 0000000..a87145b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt @@ -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) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt new file mode 100644 index 0000000..231e3a3 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt new file mode 100644 index 0000000..622da93 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt @@ -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 = + 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, + 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 = 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(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" } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt new file mode 100644 index 0000000..3b9264e --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt new file mode 100644 index 0000000..edcdbdb --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt @@ -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, + 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 + } + }