This commit is contained in:
ponzischeme89
2026-08-21 23:15:42 +12:00
parent c85fab4459
commit 8b3d80a0a6
14 changed files with 199 additions and 259 deletions
@@ -16,6 +16,7 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
@@ -457,14 +458,16 @@ internal fun AppRoot(
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
}
if (confirmingExit) {
ExitMembyConfirmation(
ExitMembyScreen(
onStay = { confirmingExit = false },
onExit = onCloseSettings,
modifier = Modifier
.fillMaxSize()
.zIndex(20f),
)
}
}
}
// ExitMembyConfirmation lives in ui/ExitConfirmation.kt — it is drawn from the design
// tokens rather than from raw Material buttons, and is screenshot-tested on its own.
// ExitMembyScreen is a small, stateless component and is screenshot-tested on its own.
@@ -1,228 +0,0 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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 com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyHairline
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.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
/** The two answers the panel offers, named so a capture can say which one is under focus. */
internal enum class ExitChoice { STAY, CLOSE }
/**
* "Close Memby?" — the last thing between a press of Back on the launcher and the TV's own
* home screen. Only drawn when the viewer has asked for it (Settings → `confirmExitMemby`).
*
* It is deliberately the only dialog in the app drawn from the design tokens rather than
* from raw `androidx.tv.material3.Button`s. Those buttons carry Material's own colour
* scheme, which nothing else on the launcher uses, so the one panel that appears *over*
* Memby was the one thing on screen that did not look like it — and the destructive choice
* and the safe one were drawn identically, on a screen read from three metres away by
* somebody who pressed a key by accident.
*
* Three things are worth keeping:
*
* - **The two actions do not look alike.** Stay is the accent fill and takes focus first;
* closing is a quiet outline. A remote has no pointer, so the loud shape is the whole of
* what says which one the viewer probably wants.
* - **Nothing here recomposes while it appears.** The entrance is one `animateFloatAsState`
* read inside `graphicsLayer`/`drawBehind` lambdas — the draw and layer phases — never in
* a composable body. This app ships to weak TV boxes and a dialog that recomposed the
* panel per frame would stutter on the way in.
* - **Back means stay.** It is the key that raised this panel, and pressing it again must
* not be the thing that closes the app.
*
* The last two parameters exist only so the panel can be captured: `ExitConfirmationScreenshotTest`
* needs the settled frame rather than whatever the animation clock happened to be at, and
* Robolectric's window never takes focus, so the focus ring — the whole of what says which
* action a press would take — cannot be photographed without being asked for.
*/
@Composable
@OptIn(ExperimentalComposeUiApi::class)
internal fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
animateIn: Boolean = true,
focusedForCapture: ExitChoice? = null,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var appeared by remember { mutableStateOf(!animateIn) }
val appear by animateFloatAsState(
targetValue = if (appeared) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "exit-dialog-appear",
)
BackHandler(onBack = onStay)
// The delay is the one the panel this replaced used: a FocusRequester attached on the
// frame it is requested on is not placed yet and the request is dropped — which on a
// dialog leaves a television with nothing focused and no way out of it.
LaunchedEffect(Unit) {
appeared = true
delay(16.milliseconds)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
// A wash rather than a flat 82% black: the launcher stays faintly readable
// underneath, so this reads as a question asked over your library rather than
// as a screen the app has navigated to.
.graphicsLayer { alpha = appear }
.background(
Brush.radialGradient(
listOf(
MembySplashTint.copy(alpha = 0.80f),
Color.Black.copy(alpha = 0.93f),
),
),
),
contentAlignment = Alignment.Center,
) {
val panelShape = RoundedCornerShape(MembyPanelCorner)
Column(
modifier = Modifier
.width(520.dp)
// Settles the last few pixels down onto the scrim as it fades in. Read in
// the layer phase, so the panel itself is composed once.
.graphicsLayer {
val t = appear
scaleX = 0.96f + 0.04f * t
scaleY = 0.96f + 0.04f * t
translationY = (1f - t) * 14.dp.toPx()
}
.shadow(30.dp, panelShape)
.clip(panelShape)
.background(MembySurfaceRaised)
.border(1.dp, MembyHairline, panelShape)
.padding(horizontal = 40.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(58.dp)
.clip(CircleShape)
.background(MembyAccentMuted)
.border(1.dp, MembyAccentBright.copy(alpha = 0.45f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
MembyIcon.Power.mark,
contentDescription = null,
tint = MembyAccentBright,
modifier = Modifier.size(27.dp),
)
}
Spacer(Modifier.height(20.dp))
Text(
"Close Memby?",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(10.dp))
Text(
"Your place is saved. You can pick up where you left off next time you " +
"open Memby.",
color = MembyMutedText,
fontSize = 16.sp,
lineHeight = 23.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
MembyDialogAction(
label = "Stay in Memby",
primary = true,
onClick = onStay,
focusedForCapture = focusedForCapture == ExitChoice.STAY,
modifier = Modifier
.weight(1f)
.focusRequester(stayFocus)
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
MembyDialogAction(
label = "Close Memby",
primary = false,
onClick = onExit,
focusedForCapture = focusedForCapture == ExitChoice.CLOSE,
modifier = Modifier
.weight(1f)
.focusRequester(exitFocus)
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
}
Spacer(Modifier.height(16.dp))
Text(
"Press Back to stay",
color = MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
@@ -0,0 +1,113 @@
package com.ponzischeme89.memby.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
/** The two answers the screen offers, named so a capture can say which one is under focus. */
internal enum class ExitChoice { STAY, CLOSE }
/** A restrained confirmation shown before Back closes Memby from the launcher. */
@Composable
@OptIn(ExperimentalComposeUiApi::class)
internal fun ExitMembyScreen(
onStay: () -> Unit,
onExit: () -> Unit,
modifier: Modifier = Modifier,
focusedForCapture: ExitChoice? = null,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
BackHandler(onBack = onStay)
LaunchedEffect(Unit) {
// Wait until the focus target has been placed before requesting focus.
delay(16.milliseconds)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = modifier.background(Color.Black.copy(alpha = 0.78f)),
contentAlignment = Alignment.Center,
) {
val panelShape = RoundedCornerShape(MembyPanelCorner)
Column(
modifier = Modifier
.width(430.dp)
.background(MembySurfaceRaised, panelShape)
.border(1.dp, MembyHairline, panelShape)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
androidx.tv.material3.Text(
"Exit Memby?",
color = MembyOnSurface,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
MembyDialogAction(
label = "Stay",
primary = true,
onClick = onStay,
focusedForCapture = focusedForCapture == ExitChoice.STAY,
modifier = Modifier
.weight(1f)
.focusRequester(stayFocus)
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
MembyDialogAction(
label = "Exit Memby",
primary = false,
onClick = onExit,
focusedForCapture = focusedForCapture == ExitChoice.CLOSE,
modifier = Modifier
.weight(1f)
.focusRequester(exitFocus)
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
}
}
}
}
@@ -830,6 +830,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
(metadata ?: item).copy(
// Detail requests enrich the facts panel, but their Fields list does not ask Emby
// for artwork. Keep the row card's image identity when that sparse response arrives
// after focus settles. This matters again after the 32-entry metadata cache turns
// over: revisiting an early row fetches details again, and replacing the card
// wholesale used to drop its Logo tag and switch the hero back to a text heading.
// Merge ImageTags individually because a detail response may carry Primary while
// still omitting Logo; treating a non-empty map as authoritative loses the latter.
imageTags = item.imageTags + metadata?.imageTags.orEmpty(),
backdropImageTags = metadata?.backdropImageTags
?.takeIf { it.isNotEmpty() }
?: item.backdropImageTags,
primaryImageAspectRatio = metadata?.primaryImageAspectRatio
?: item.primaryImageAspectRatio,
parentBackdropItemId = metadata?.parentBackdropItemId
?: item.parentBackdropItemId,
parentBackdropImageTags = metadata?.parentBackdropImageTags
?.takeIf { it.isNotEmpty() }
?: item.parentBackdropImageTags,
parentLogoItemId = metadata?.parentLogoItemId
?: item.parentLogoItemId,
parentLogoImageTag = metadata?.parentLogoImageTag
?: item.parentLogoImageTag,
userData = item.userData ?: metadata?.userData,
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
membyRecommendationReason = item.membyRecommendationReason
@@ -250,7 +250,7 @@ internal fun MembyChoiceChip(
}
/**
* One answer in a full-stop dialog: the shape [ExitMembyConfirmation] draws and the shape
* One answer in a full-stop dialog: the shape [ExitMembyScreen] draws and the shape
* every panel that asks a yes/no question over the app should draw.
*
* The two answers must never look alike — a remote has no pointer, so the loud shape is the
@@ -163,7 +163,7 @@ internal fun ProfileChooser(
* Draws the screen as though the remote were on this card, so a capture can show the
* focused avatar and the revealed remove control at once. Robolectric's window never
* takes focus and the focus state is most of what this screen has to say — the flag
* `ViewerActionButton` and `ExitMembyConfirmation` carry, for the same reason. The index
* `ViewerActionButton` and `ExitMembyScreen` carry, for the same reason. The index
* one past the last profile is the Add card.
*/
focusedCardForCapture: Int? = null,
@@ -654,7 +654,7 @@ private fun ProfileRemoveButton(
* "Remove Matt from this TV?" — the full stop between one accidental press and a sign-in
* somebody has to type back in on a remote.
*
* Drawn from the design tokens in the shape `ExitMembyConfirmation` set, and for the same
* Drawn from the design tokens in the shape `ExitMembyScreen` set, and for the same
* reason: the two answers must not look alike. Keeping them is the accent fill and takes
* focus first; removing is a quiet outline that turns red only once the remote is on it.
* Back cancels — it is the key most likely to be pressed by somebody who did not mean to be
@@ -195,7 +195,7 @@ internal enum class SettingsPage(
// its download URL with it.
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
ABOUT("About", "Version and release notes", MembyIcon.Info),
ABOUT("About", "Version/changelog", MembyIcon.Info),
}
// Black, and one lit thing at a time.
@@ -1111,7 +1111,7 @@ internal fun SettingsPanelContent(
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
stringResource(R.string.app_name),
"Version",
color = TextPrimary,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
@@ -1131,7 +1131,7 @@ internal fun SettingsPanelContent(
}
SettingDivider()
VersionRow(
label = "Memby gateway",
label = "Gateway server version",
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
)
}
@@ -46,7 +46,7 @@ import com.ponzischeme89.memby.ui.theme.mark
* [emphasised] marks the answer the screen is *for* — adding the person, saving the name —
* so that on a row of two the destructive or the neutral one is never the one wearing the
* accent. It is a quiet outline the rest of the time, which is the same distinction
* `ExitConfirmation` draws between staying and closing.
* `ExitMembyScreen` draws between staying and closing.
*/
@Composable
internal fun ViewerActionButton(
@@ -59,7 +59,7 @@ internal fun ViewerActionButton(
/**
* Draws the button as though the remote were on it. Robolectric's window never takes
* focus and the ring is the whole of what says which answer a press would take, so a
* capture of a two-answer row would otherwise prove nothing — the flag `ExitConfirmation`
* capture of a two-answer row would otherwise prove nothing — the flag `ExitMembyScreen`
* carries, for the same reason.
*/
focusedForCapture: Boolean = false,
@@ -262,7 +262,7 @@ private fun ViewerManageRow(
* The full-stop question, because removing somebody deletes everything Memby kept for them
* and there is nothing to undo it with.
*
* It follows `ExitConfirmation`'s rules, which are the app's rules for a question asked over
* It follows `ExitMembyScreen`'s rules, which are the app's rules for a question asked over
* the thing it is about: the two answers **do not look alike**, the safe one takes focus
* first, and **Back means keep** — it is the key that raised the panel, and pressing it
* again must not be what deletes a person.
@@ -13,6 +13,42 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ContinueWatchingResumeTest {
@Test
fun `detail metadata keeps artwork carried by the row card`() {
val card = BaseItem(
id = "episode",
name = "The Episode",
type = "Episode",
imageTags = mapOf("Primary" to "card-primary", "Logo" to "logo-tag"),
backdropImageTags = listOf("backdrop-tag"),
primaryImageAspectRatio = 0.67,
parentBackdropItemId = "series",
parentBackdropImageTags = listOf("parent-backdrop-tag"),
parentLogoItemId = "series",
parentLogoImageTag = "parent-logo-tag",
)
val sparseDetails = BaseItem(
id = "episode",
name = "The Episode",
type = "Episode",
overview = "The richer record fetched after focus.",
// A detail response can still carry one image type without carrying Logo.
imageTags = mapOf("Primary" to "detail-primary"),
)
val focused = focusedItemWithMetadata(card, sparseDetails)
assertEquals("The richer record fetched after focus.", focused.overview)
assertEquals("detail-primary", focused.imageTags["Primary"])
assertEquals("logo-tag", focused.imageTags["Logo"])
assertEquals(listOf("backdrop-tag"), focused.backdropImageTags)
assertEquals(0.67, focused.primaryImageAspectRatio)
assertEquals("series", focused.parentBackdropItemId)
assertEquals(listOf("parent-backdrop-tag"), focused.parentBackdropImageTags)
assertEquals("series", focused.parentLogoItemId)
assertEquals("parent-logo-tag", focused.parentLogoImageTag)
}
@Test
fun `detail metadata keeps the continue watching playhead and resume label`() {
val card = BaseItem(
@@ -35,9 +35,8 @@ import org.robolectric.annotation.GraphicsMode
* .\gradlew.bat :app:testDebugUnitTest --tests "*ExitConfirmationScreenshotTest"
* ```
*
* It is drawn over a stand-in launcher rather than over black, because the whole point of
* the wash behind it is that the library stays faintly readable underneath — a capture on
* a blank background would prove nothing about the one thing worth looking at.
* It is drawn over a stand-in launcher so the simple dimmed backdrop can be judged in its
* real context.
*
* The second capture repaints under a different palette. This panel is the only full-stop
* dialog in the app and it is now drawn entirely from the tokens, so a theme that cannot
@@ -92,12 +91,10 @@ class ExitConfirmationScreenshotTest {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface)) {
StandInLauncher()
// Settled rather than mid-entrance: the frame worth looking at is the one
// somebody actually reads.
ExitMembyConfirmation(
ExitMembyScreen(
onStay = {},
onExit = {},
animateIn = false,
modifier = Modifier.fillMaxSize(),
// Robolectric's window never takes focus, so a capture that pressed
// its way onto a button would photograph every action unfocused.
focusedForCapture = focused,