This commit is contained in:
ponzischeme89
2026-08-22 11:32:50 +12:00
parent b131932a4e
commit f982d391b9
40 changed files with 25594 additions and 9575 deletions
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
private val _preferencesRevision = MutableStateFlow(0L)
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _heroRevision = MutableStateFlow("")
private val _installPermissionPrompt = MutableStateFlow(false)
@@ -134,6 +135,8 @@ class MaintenanceMonitor(
/** One household-wide composition, delivered by the status poll to every viewer. */
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
val metadataHeroTimeRemainingColour: StateFlow<String> =
_metadataHeroTimeRemainingColour.asStateFlow()
/**
* Whether the operator wants TVs that cannot install their own updates to be asked for
@@ -188,18 +191,18 @@ class MaintenanceMonitor(
*/
val requestsAllowed: StateFlow<Boolean> = _requestsAllowed.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */
/** Build reported by the connected gateway, for the Settings rail. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
/**
* The Emby build the gateway is talking to, printed beside the gateway's own on
* Settings → About. Blank whenever it is not known — no probe has answered yet, the
* probe is switched off, or the gateway predates the field — and About then prints the
* Settings rail. Blank whenever it is not known — no probe has answered yet, the
* probe is switched off, or the gateway predates the field — and the rail then prints the
* gateway's version alone rather than an empty pair of brackets.
*
* Deliberately *not* cleared when the gateway drops out, unlike [gatewayVersion]. That
* one describes a connection that is now gone; this describes a server that is still
* whatever version it was, and About is exactly the page somebody opens when something
* whatever version it was, and Settings is exactly where somebody looks when something
* has stopped working.
*/
val embyVersion: StateFlow<String> = _embyVersion.asStateFlow()
@@ -283,8 +286,9 @@ class MaintenanceMonitor(
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_heroRevision.value = ""
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -323,6 +327,12 @@ class MaintenanceMonitor(
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
.distinct()
.ifEmpty { DEFAULT_METADATA_HERO_CONTENT_ORDER }
_metadataHeroTimeRemainingColour.value =
status.metadataHeroTimeRemainingColour
.trim()
.lowercase()
.takeIf { it == METADATA_HERO_TIME_COLOUR_WHITE }
?: METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = status.theme
_heroRevision.value = status.hero.revision
_installPermissionPrompt.value =
@@ -339,7 +349,7 @@ class MaintenanceMonitor(
// should not have that fact disappear from the poll.
_embyOutage.value = outageFrom(status.emby)
// Beside the outage, and for the same reason: reported even
// during maintenance, since an operator looking at About while
// during maintenance, since an operator looking at Settings while
// Memby is down still wants to know what Emby is running.
status.emby.version.takeIf(String::isNotBlank)?.let {
_embyVersion.value = it
@@ -367,8 +377,9 @@ class MaintenanceMonitor(
_embyOutage.value = null
_preferencesRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_heroRevision.value = ""
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -475,6 +486,8 @@ class MaintenanceMonitor(
listOf("title", "ratings", "facts", "summary")
internal val METADATA_HERO_CONTENT_OPTIONS =
DEFAULT_METADATA_HERO_CONTENT_ORDER + listOf("recommendation_reason", "time_remaining")
internal const val METADATA_HERO_TIME_COLOUR_GREEN = "green"
internal const val METADATA_HERO_TIME_COLOUR_WHITE = "white"
internal const val POLL_INTERVAL_MS = 10_000L
internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
val preferencesRevision: Long = 0,
/** Household-wide order of the focused metadata hero's content blocks. */
val metadataHeroContentOrder: List<String> = emptyList(),
/** Household-wide colour for the optional time-remaining caption: green or white. */
val metadataHeroTimeRemainingColour: String = "green",
/**
* The colour scheme this viewer's televisions should be painted, as an id and a
* revision rather than the palette itself — the [preferencesRevision] precedent, for
@@ -317,9 +319,9 @@ data class GatewayEmbyHealth(
val checkedAt: String = "",
val retrySeconds: Int = 0,
/**
* Emby's own version, for the About page. Blank when no probe has answered yet, when the
* Emby's own version, for the Settings rail. Blank when no probe has answered yet, when the
* probe is switched off, and on a gateway that predates the field — all three of which
* the page renders the same way, by printing the gateway's version alone.
* rail renders the same way, by printing the gateway's version alone.
*/
val version: String = "",
) {
@@ -284,6 +284,7 @@ internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
internal fun FocusedHomeMetadata(
homeViewModel: HomeViewModel,
metadataHeroContentOrder: List<String>,
metadataHeroTimeRemainingColour: String,
modifier: Modifier = Modifier,
) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
@@ -295,6 +296,7 @@ internal fun FocusedHomeMetadata(
item = focusedItem,
loading = homeContent.loading.isNotEmpty(),
contentOrder = metadataHeroContentOrder,
timeRemainingColour = metadataHeroTimeRemainingColour,
modifier = modifier,
)
}
@@ -224,6 +224,8 @@ internal fun HomeScreen(
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val metadataHeroContentOrder by
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
val metadataHeroTimeRemainingColour by
ServiceLocator.maintenance.metadataHeroTimeRemainingColour.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
@@ -286,9 +288,6 @@ internal fun HomeScreen(
var quickMenuTrailerAvailable by remember { mutableStateOf(false) }
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var rowBrowseTarget by remember { mutableStateOf<HomeRowBrowseTarget?>(null) }
var rowBrowseOriginRowId by remember { mutableStateOf<String?>(null) }
var rowBrowseOriginItemId by remember { mutableStateOf<String?>(null) }
var sectionHeroRows by remember(settings.userId) {
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
}
@@ -847,7 +846,6 @@ internal fun HomeScreen(
)
LaunchedEffect(selectedDestination) {
focusedHomeRowId = null
rowBrowseTarget = null
}
LaunchedEffect(selectedDestination, settings.forYouMinutes) {
if (
@@ -868,7 +866,6 @@ internal fun HomeScreen(
showNotifications -> "notifications"
userSwitcherVisible || showProfiles -> "profiles"
detailsItem != null -> "details"
rowBrowseTarget != null -> "row_browse"
selectedMyShow != null -> "my_show_details"
else -> selectedDestination.name.lowercase()
}
@@ -941,7 +938,6 @@ internal fun HomeScreen(
navigationExpanded = it
},
onDestinationSelected = { destination ->
rowBrowseTarget = null
homeViewModel.trackJourney(
category = "navigation", action = "select",
screen = journeyScreen, feature = destination.name.lowercase(),
@@ -1057,53 +1053,6 @@ internal fun HomeScreen(
return@BoxWithConstraints
}
rowBrowseTarget?.let { browseTarget ->
GenreBrowseScreen(
itemType = browseTarget.itemType,
initialCategoryId = browseTarget.categoryId,
favouriteStates = favoriteChanges,
playedStates = playedChanges,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf {
returnRowId == ROW_BROWSE_RESULTS_ID
},
returnFocusRequester = cardReturnFocusRequester,
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = ROW_BROWSE_RESULTS_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
category = "content", action = "open", screen = "row_browse",
feature = "view_all", source = "row_browse_results",
target = "details", itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsItem = item
},
onContentFocused = { navigationExpanded = false },
onClose = {
rowBrowseTarget = null
returnRowId = rowBrowseOriginRowId
returnRowKind = rows.firstOrNull {
it.id == rowBrowseOriginRowId
}?.kind?.name
returnItemId = rowBrowseOriginItemId
scope.launch {
delay(16.milliseconds)
if (rowBrowseOriginItemId != null) {
runCatching { cardReturnFocusRequester.requestFocus() }
} else {
runCatching { contentFocusRequester.requestFocus() }
}
}
},
)
return@BoxWithConstraints
}
if (selectedDestination == BrowseDestination.SEARCH) {
// Remembered, or this list is a fresh instance on every recomposition
// and the screen re-derives its genre chips each time.
@@ -1432,6 +1381,7 @@ internal fun HomeScreen(
FocusedHomeMetadata(
homeViewModel = homeViewModel,
metadataHeroContentOrder = metadataHeroContentOrder,
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
modifier = Modifier
.height(metadataHeight)
// LazyColumn is drawn later as a sibling. Keep the hero
@@ -1565,7 +1515,6 @@ internal fun HomeScreen(
row.items.take(8).map { it.id },
)
}
val browseTarget = homeRowBrowseTarget(row)
MediaRow(
modifier = Modifier,
row = row,
@@ -1727,24 +1676,6 @@ internal fun HomeScreen(
horizontalState = horizontalStates.getOrPut(
"${selectedDestination.name}:${row.id}",
) { LazyListState() },
viewAllLabel = browseTarget?.actionLabel,
onViewAll = browseTarget?.let { target ->
{
rowBrowseOriginRowId = row.id
rowBrowseOriginItemId = returnItemId.takeIf {
returnRowId == row.id
} ?: row.items.getOrNull(
rowFocusPositions[row.id] ?: 0,
)?.id
rowBrowseTarget = target
homeViewModel.trackJourney(
category = "navigation", action = "open",
screen = selectedDestination.name.lowercase(),
feature = "view_all", source = row.id,
target = "row_browse",
)
}
},
)
}
}
@@ -35,6 +35,7 @@ internal fun MetadataHero(
item: BaseItem?,
loading: Boolean,
contentOrder: List<String> = emptyList(),
timeRemainingColour: String = "green",
modifier: Modifier = Modifier,
) {
Box(
@@ -71,6 +72,7 @@ internal fun MetadataHero(
item = item,
loading = loading,
contentOrder = contentOrder,
timeRemainingColour = timeRemainingColour,
modifier = Modifier
.fillMaxSize()
.padding(
@@ -104,7 +104,7 @@ internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
null
}
return when {
code != null && cleanName.isNotEmpty() -> "$code - $cleanName"
code != null && cleanName.isNotEmpty() -> "$code · $cleanName"
code != null -> code
cleanName.isNotEmpty() -> cleanName
else -> null
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.weight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,7 +50,6 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
@@ -134,6 +132,7 @@ fun MediaMetadataPanel(
item: BaseItem?,
loading: Boolean,
contentOrder: List<String>,
timeRemainingColour: String,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier) {
@@ -157,7 +156,13 @@ fun MediaMetadataPanel(
Text("Choose something to watch.", color = MutedText, fontSize = 14.sp)
}
}
else -> MetadataContent(item, contentWidth, compactLayout, contentOrder)
else -> MetadataContent(
item,
contentWidth,
compactLayout,
contentOrder,
timeRemainingColour,
)
}
}
}
@@ -201,6 +206,7 @@ private fun MetadataContent(
contentWidth: Dp,
compact: Boolean,
contentOrder: List<String>,
timeRemainingColour: String,
) {
if (item.isSchedule) {
ScheduleMetadataContent(item, contentWidth, compact)
@@ -271,7 +277,7 @@ private fun MetadataContent(
Modifier.fillMaxWidth(),
)
}
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item)
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item, timeRemainingColour)
MetadataHeroSection.Summary -> Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
@@ -336,13 +342,17 @@ private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, c
fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (item.isEpisode && item.seriesName != null) Text(
item.episodeCode
?.filter { it.isLetterOrDigit() }
?.lowercase(Locale.ROOT)
.orEmpty(),
color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (item.isEpisode) {
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
Text(
label,
color = MutedText,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
@@ -434,7 +444,7 @@ private fun MetadataStatus(item: BaseItem) {
}
@Composable
private fun MetadataTimeRemaining(item: BaseItem) {
private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L
val minutes = metadataHeroMinutesRemaining(item) ?: return
@@ -453,15 +463,19 @@ private fun MetadataTimeRemaining(item: BaseItem) {
)
}
Text(
if (minutes == 1) "1 MINUTE REMAINING" else "$minutes MINUTES REMAINING",
color = MutedText,
metadataHeroTimeRemainingLabel(minutes),
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
fontSize = 13.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
internal fun metadataHeroTimeRemainingLabel(minutes: Long): String =
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? {
val position = item.userData?.playbackPositionTicks ?: return null
val runtime = item.runTimeTicks ?: return null
@@ -12,7 +12,6 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -22,23 +21,17 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
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.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.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@@ -54,8 +47,6 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
@@ -175,20 +166,10 @@ internal fun MediaRow(
density: String = "standard",
artworkStyle: String = "automatic",
horizontalState: LazyListState? = null,
viewAllLabel: String? = null,
onViewAll: (() -> Unit)? = null,
) {
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val rowState = horizontalState ?: savedRowState
val verticalEntryFocusRequester = remember { FocusRequester() }
val previousPageFocusRequester = remember { FocusRequester() }
val nextPageFocusRequester = remember { FocusRequester() }
val viewAllFocusRequester = remember { FocusRequester() }
val headerReturnFocusRequester = remember { FocusRequester() }
val pagedCardFocusRequester = remember { FocusRequester() }
var headerReturnItemId by remember(row.id) { mutableStateOf<String?>(null) }
var pagedCardIndex by remember(row.id) { mutableStateOf<Int?>(null) }
var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) }
val requestedEntryIndex = verticalFocusRequest
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex
@@ -216,39 +197,6 @@ internal fun MediaRow(
}
currentOnVerticalFocusRequestConsumed(request.requestId)
}
LaunchedEffect(pageFocusRequestId) {
val target = pagedCardIndex ?: return@LaunchedEffect
rowState.scrollToItem(target)
// The target can begin outside the composed LazyRow window. Give layout a frame to
// attach it, then keep the transfer bounded so a refreshed row can never trap focus.
repeat(6) {
delay(16.milliseconds)
if (pagedCardFocusRequester.requestFocusIfAttached()) {
return@LaunchedEffect
}
}
}
// 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.canScrollBackward }
}
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
val hasViewAll = viewAllLabel != null && onViewAll != null
val headerReturnTargetId = headerReturnItemId
?.takeIf { focusedId -> row.items.any { it.id == focusedId } }
?: row.items.firstOrNull()?.id
fun requestHeaderFocus(): Boolean = when {
hasViewAll -> viewAllFocusRequester.requestFocusIfAttached()
canScrollBack -> previousPageFocusRequester.requestFocusIfAttached()
canScrollForward -> nextPageFocusRequester.requestFocusIfAttached()
else -> false
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
Row(
modifier = Modifier
@@ -266,42 +214,6 @@ internal fun MediaRow(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) {
if (viewAllLabel != null && onViewAll != null) {
ViewAllButton(
label = viewAllLabel,
focusRequester = viewAllFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = onViewAll,
)
Spacer(Modifier.width(8.dp))
}
GalleryJumpButton(
forward = false,
enabled = canScrollBack,
focusRequester = previousPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
pagedCardIndex = target
pageFocusRequestId += 1
},
)
Spacer(Modifier.width(8.dp))
GalleryJumpButton(
forward = true,
enabled = canScrollForward,
focusRequester = nextPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = {
val target = (rowState.firstVisibleItemIndex + pageSize)
.coerceAtMost(row.items.lastIndex)
pagedCardIndex = target
pageFocusRequestId += 1
},
)
}
}
when {
row.items.isEmpty() && row.loading -> {
@@ -369,52 +281,22 @@ internal fun MediaRow(
if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester)
}
if (item.id == headerReturnTargetId) {
cardModifier = cardModifier.focusRequester(headerReturnFocusRequester)
}
if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
}
if (index == pagedCardIndex) {
cardModifier = cardModifier.focusRequester(pagedCardFocusRequester)
}
cardModifier = cardModifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (event.key) {
Key.DirectionUp -> {
requestHeaderFocus() ||
onMoveVertical(index, RowFocusDirection.UP)
onMoveVertical(index, RowFocusDirection.UP)
}
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
Key.DirectionLeft -> {
val firstVisible = rowState.layoutInfo.visibleItemsInfo
.firstOrNull()?.index
if (canScrollBack && index == firstVisible) {
previousPageFocusRequester.requestFocusIfAttached()
} else {
false
}
}
Key.DirectionRight -> {
val lastVisible = rowState.layoutInfo.visibleItemsInfo
.lastOrNull()?.index
if (index != lastVisible) {
false
} else if (viewAllLabel != null && onViewAll != null) {
viewAllFocusRequester.requestFocusIfAttached()
} else if (canScrollForward) {
nextPageFocusRequester.requestFocusIfAttached()
} else {
false
}
}
else -> false
}
}
val focused: () -> Unit = {
headerReturnItemId = item.id
onContentFocused()
// LazyRow already knows the semantic position. Passing it on
// avoids searching the row again on every D-pad focus move.
@@ -453,105 +335,6 @@ internal fun MediaRow(
}
}
@Composable
private fun GalleryJumpButton(
forward: Boolean,
enabled: Boolean,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
onFocused = {},
onClick = { if (enabled) onClick() },
onLongClick = null,
contentDescription = if (forward) "Next page" else "Previous page",
modifier = Modifier
.width(42.dp)
.height(36.dp)
.focusRequester(focusRequester)
.focusProperties {
canFocus = enabled
down = downFocusRequester
},
) { focused ->
Box(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(MembyChipCorner))
.background(
when {
focused -> MembyAccent.copy(alpha = 0.34f)
enabled -> Color.White.copy(alpha = 0.08f)
else -> Color.White.copy(alpha = 0.03f)
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(MembyChipCorner),
),
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),
)
}
}
}
@Composable
private fun ViewAllButton(
label: String,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
onFocused = {},
onClick = onClick,
contentDescription = label,
modifier = Modifier
.height(36.dp)
.focusRequester(focusRequester)
.focusProperties { down = downFocusRequester },
) { focused ->
Row(
modifier = Modifier
.height(36.dp)
.clip(RoundedCornerShape(MembyChipCorner))
.background(
if (focused) MembyAccent.copy(alpha = 0.34f)
else Color.White.copy(alpha = 0.08f),
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(MembyChipCorner),
)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
Icon(
imageVector = MembyIcon.ChevronRight.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(17.dp),
)
}
}
}
private fun FocusRequester.requestFocusIfAttached(): Boolean =
runCatching { requestFocus() }.getOrDefault(false)
@@ -1,59 +0,0 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID
import com.ponzischeme89.memby.ui.genre.genreCategoryTabs
internal const val ROW_BROWSE_RESULTS_ID = "row-browse-results"
/** The existing paged catalogue screen a home shelf can expand into. */
internal data class HomeRowBrowseTarget(
val categoryId: String,
val itemType: String,
val actionLabel: String,
)
/**
* Resolves only shelves whose complete catalogue query is known on both backend paths.
*
* Recommendation and studio shelves deliberately return null: the horizontal row is a
* ranked answer, while the catalogue browser can currently page only media types and
* genres. Labelling an unfiltered library as "all Pixar" would be worse than omitting the
* action until that filter exists.
*/
internal fun homeRowBrowseTarget(row: HomeBrowseRow): HomeRowBrowseTarget? {
if (row.id == "latest-movies") {
return HomeRowBrowseTarget(
categoryId = ALL_MEDIA_CATEGORY_ID,
itemType = "Movie",
actionLabel = "View All",
)
}
val itemType = when {
row.id.startsWith("curated:movies:genre:") -> "Movie"
row.id.startsWith("curated:shows:genre:") -> "Series"
row.id.startsWith("curated:") && row.id.endsWith("-shows") -> "Series"
else -> return null
}
val rowLabel = row.title
.removeSuffix(" TV Shows")
.removeSuffix(" Shows")
.removeSuffix(" Movies")
.trim()
val normalisedLabel = rowLabel.normalisedBrowseLabel()
val category = genreCategoryTabs(itemType).firstOrNull { candidate ->
candidate.id != ALL_MEDIA_CATEGORY_ID && (
candidate.label.normalisedBrowseLabel() == normalisedLabel ||
candidate.genres.any { it.normalisedBrowseLabel() == normalisedLabel }
)
} ?: return null
return HomeRowBrowseTarget(
categoryId = category.id,
itemType = itemType,
actionLabel = "View All ${category.label}",
)
}
private fun String.normalisedBrowseLabel(): String =
lowercase().filter(Char::isLetterOrDigit)
@@ -195,7 +195,6 @@ 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/changelog", MembyIcon.Info),
}
// Black, and one lit thing at a time.
@@ -266,7 +265,6 @@ internal data class SettingsPanelState(
val installedVersion: String = "",
val gatewayVersion: String = "",
val embyVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(),
val devicesLoading: Boolean = false,
val devicesError: String? = null,
@@ -337,8 +335,8 @@ fun SettingsSheet(
withContext(NonCancellable) { block() }
}
}
// Kept only for the version this TV is running, which About prints. Nothing here checks
// for an update: see the note on SettingsPage.
// Kept only for the version this TV is running, which the Settings rail prints. Nothing
// here checks for an update: see the note on SettingsPage.
val checker = remember { UpdateChecker(context) }
// Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects
// default chips before DataStore emits; a viewer could press "Automatic" in that gap
@@ -804,6 +802,9 @@ internal fun SettingsPanelContent(
SettingsSecondaryRail(
selected = state.selectedPage,
onSelected = actions.onPageSelected,
installedVersion = state.installedVersion,
gatewayVersion = state.gatewayVersion,
embyVersion = state.embyVersion,
firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
@@ -1104,43 +1105,6 @@ internal fun SettingsPanelContent(
SettingsNotice("Stored artwork cleared.", positive = true)
}
}
SettingsPage.ABOUT -> SettingsGroup {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
"Version",
color = TextPrimary,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
color = TextSecondary,
fontSize = 12.sp,
)
}
Text(
"v${state.installedVersion}",
color = EmbyGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
SettingDivider()
VersionRow(
label = "Gateway server version",
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
)
}
}
if (state.selectedPage == SettingsPage.ABOUT) {
VersionHistorySection(
releases = state.releaseHistory,
installedVersion = state.installedVersion,
)
}
Spacer(Modifier.height(12.dp))
}
@@ -1148,7 +1112,7 @@ internal fun SettingsPanelContent(
}
/**
* What the About page prints beside "Memby gateway" — the gateway's own build, with the Emby
* What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby
* it is talking to in brackets after it: `0.1.50 (4.10.0.21)`.
*
* The two are one row rather than two because they are one fact: this gateway, against that
@@ -1365,6 +1329,9 @@ private const val SETTINGS_PAGE_SETTLE_MS = 130L
private fun SettingsSecondaryRail(
selected: SettingsPage,
onSelected: (SettingsPage) -> Unit,
installedVersion: String,
gatewayVersion: String,
embyVersion: String,
firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?,
contentFocusRequester: FocusRequester,
@@ -1532,6 +1499,24 @@ private fun SettingsSecondaryRail(
}
}
Spacer(Modifier.height(16.dp))
Column(
modifier = Modifier.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Memby v${installedVersion.ifBlank { "—" }}",
color = TextSecondary,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"Memby Server ${gatewayVersionLabel(gatewayVersion, embyVersion)}",
color = TextQuiet,
fontSize = 10.sp,
lineHeight = 14.sp,
)
}
Spacer(Modifier.height(8.dp))
Text(
"↑ ↓ moves between pages",
color = TextQuiet,
@@ -1557,9 +1542,8 @@ private fun SettingsHeader(page: SettingsPage) {
// already inside Memby on a television, so it named the one thing nobody could be in
// doubt about while taking the vertical space the settings themselves want.
//
// No version in the corner either, for the same reason it lost the eyebrow: it was
// on all seven pages to answer a question asked on two of them, where Updates prints
// it as "On this TV" and About prints it beside the app's own name.
// Version details live beneath the navigation in the rail, where they remain visible
// without taking a page of their own.
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text(
@@ -1590,7 +1574,7 @@ private fun VersionRow(label: String, version: String) {
/**
* A run of settings on the black canvas.
*
* There is no card and no icon chip. Every page but About holds exactly one of these, and
* There is no card and no icon chip. Every page holds exactly one of these, and
* the header above it already names the page — a titled box inside a titled page said the
* same word twice and cost two nested surfaces to do it. [label] exists only for the pages
* that genuinely have two groups, and it is a quiet caption rather than a second heading.
@@ -1885,113 +1869,6 @@ private fun SettingsNotice(text: String, positive: Boolean) {
)
}
/**
* The release history, as a list of collapsed releases that open in place. A remote has
* no scrollbar to aim at, so the whole changelog rendered flat would be a very long blind
* scroll; one focusable row per release makes Down mean "next release" until the viewer
* asks for a particular one. The newest is open on arrival because that is the one being
* looked for.
*/
@Composable
private fun VersionHistorySection(
releases: List<ReleaseNote>,
installedVersion: String,
) {
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
mutableStateOf(releases.firstOrNull()?.version)
}
SettingsGroup(label = "Changelog") {
if (releases.isEmpty()) {
SettingsNotice("No release history shipped with this build.", positive = false)
return@SettingsGroup
}
releases.forEachIndexed { index, release ->
if (index > 0) SettingDivider()
ReleaseHistoryRow(
release = release,
installed = release.version == installedVersion,
expanded = release.version == expandedVersion,
onToggle = {
expandedVersion = if (expandedVersion == release.version) null else release.version
},
)
}
}
}
@Composable
private fun ReleaseHistoryRow(
release: ReleaseNote,
installed: Boolean,
expanded: Boolean,
onToggle: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) RowFocused else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onToggle)
.testTag("settings-release-${release.version}")
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"v${release.version}",
color = if (installed) EmbyGreen else TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
if (installed) {
Text(
"INSTALLED",
color = EmbyGreen,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
)
}
Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let {
Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
color = if (focused) MembyAccentInk else EmbyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
.padding(horizontal = 11.dp, vertical = 7.dp),
)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
release.changes.forEach { change ->
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text("", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(change, color = TextSecondary, fontSize = 13.sp, lineHeight = 19.sp)
}
}
}
}
}
}
@Composable
private fun SettingDivider() {
Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline))
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
@@ -56,7 +57,6 @@ import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.PushPin
@@ -111,7 +111,7 @@ object MaterialIconPack {
MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder },
MembyIcon.Bookmark to { Icons.Default.Bookmark },
MembyIcon.BookmarkOutline to { Icons.Default.BookmarkBorder },
MembyIcon.PlaylistAdd to { Icons.Default.PlaylistAdd },
MembyIcon.PlaylistAdd to { Icons.AutoMirrored.Filled.PlaylistAdd },
MembyIcon.PlaylistRemove to { Icons.Default.PlaylistRemove },
MembyIcon.HideWatched to { Icons.Default.VisibilityOff },
MembyIcon.Check to { Icons.Default.Check },
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.update
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -54,6 +55,7 @@ object RequiredUpdateSignal {
* verdict that says this build is fine is exactly the evidence that the refusal is
* spent.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun clear() {
_required.resetReplayCache()
}
@@ -146,13 +146,14 @@ class GatewayPayloadTest {
@Test
fun `decodes explicit client server protocol mismatch`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1}""",
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1,"metadataHeroTimeRemainingColour":"white"}""",
)
assertEquals(false, status.compatible)
assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
assertEquals("0.1.29", status.gatewayVersion)
assertEquals(1, status.serverProtocol)
assertEquals("white", status.metadataHeroTimeRemainingColour)
}
@Test
@@ -1,61 +0,0 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HomeRowBrowseTest {
@Test
fun `recent releases opens the complete movie catalogue`() {
val target = homeRowBrowseTarget(row("latest-movies", "Recent New Releases", MediaRowKind.MOVIES))
assertEquals("all", target?.categoryId)
assertEquals("Movie", target?.itemType)
assertEquals("View All", target?.actionLabel)
}
@Test
fun `movie and show genre rows open their matching paged catalogue`() {
val crime = homeRowBrowseTarget(
row("curated:movies:genre:crime", "Crime Movies", MediaRowKind.MOVIES),
)
val drama = homeRowBrowseTarget(
row("curated:shows:genre:drama", "Drama Shows", MediaRowKind.SHOWS),
)
assertEquals("crime", crime?.categoryId)
assertEquals("Movie", crime?.itemType)
assertEquals("View All Crime", crime?.actionLabel)
assertEquals("drama", drama?.categoryId)
assertEquals("Series", drama?.itemType)
assertEquals("View All Drama", drama?.actionLabel)
}
@Test
fun `legacy show genres remain browsable`() {
val target = homeRowBrowseTarget(
row("curated:comedy-shows", "Comedy TV Shows", MediaRowKind.SHOWS),
)
assertEquals("comedy", target?.categoryId)
assertEquals("Series", target?.itemType)
}
@Test
fun `ranked and studio rows do not claim an unrelated complete catalogue`() {
assertNull(homeRowBrowseTarget(row("recommended", "Recommended", MediaRowKind.MOVIES)))
assertNull(
homeRowBrowseTarget(
row("curated:movies:studio:pixar", "More from Pixar", MediaRowKind.MOVIES),
),
)
}
private fun row(id: String, title: String, kind: MediaRowKind) = HomeBrowseRow(
id = id,
title = title,
items = emptyList(),
kind = kind,
emptyMessage = "",
)
}
@@ -1,86 +0,0 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class HomeRowFocusTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `row action returns focus to the card that opened it`() {
compose.setContent {
PreviewSurface {
MediaRow(
row = HomeBrowseRow(
id = "latest-movies",
title = "Recent releases",
items = listOf(
BaseItem(id = "first", name = "First film", type = "Movie"),
BaseItem(id = "second", name = "Second film", type = "Movie"),
),
kind = MediaRowKind.MOVIES,
emptyMessage = "No films",
),
availableWidth = 960.dp,
contentEntryFocusRequester = null,
returnFocusItemId = null,
returnFocusRequester = remember { FocusRequester() },
verticalFocusRequest = null,
onVerticalFocusRequestConsumed = {},
onMoveVertical = { _, _ -> false },
onContentFocused = {},
onItemFocused = { _, _ -> },
onItemSelected = {},
onItemLongPressed = {},
viewAllLabel = "View All",
onViewAll = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
compose.waitForIdle()
val secondCard = compose.onNodeWithContentDescription("Second film", substring = true)
secondCard.requestFocus()
compose.waitForIdle()
secondCard.assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithContentDescription("View All").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
secondCard.assertIsFocused()
}
}
@@ -66,5 +66,7 @@ class MetadataHeroOrderTest {
)
assertEquals(60L, metadataHeroMinutesRemaining(item))
assertEquals("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
assertEquals("1 minute remaining", metadataHeroTimeRemainingLabel(1L))
}
}
@@ -18,12 +18,12 @@ class ResumableMediaCardTest {
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
assertEquals("The Show", model.title)
assertEquals("S01E01 - Pilot", model.episodeLabel)
assertEquals("S01E01 · Pilot", model.episodeLabel)
}
@Test
fun episodeIdentityIncludesName() {
assertEquals("S01E01 - Pilot", episodeLabel(1, 1, "Pilot"))
assertEquals("S01E01 · Pilot", episodeLabel(1, 1, "Pilot"))
}
@Test
@@ -10,7 +10,6 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
@@ -32,7 +31,7 @@ class SettingsRailFocusTest {
val compose = createComposeRule()
@Test
fun `up from about reaches storage`() {
fun `up from storage reaches devices`() {
compose.setContent { Fixture() }
compose.waitForIdle()
@@ -43,34 +42,11 @@ class SettingsRailFocusTest {
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").assertIsFocused()
}
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
// The rail settles onto the page a beat after the focus lands on it; the press
// worth testing is the one made after the About page is actually drawn.
compose.waitUntil {
compose.onAllNodesWithTag("settings-page-about").fetchSemanticsNodes().isNotEmpty()
}
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-storage").assertIsFocused()
}
@Test
fun `up from the top of the about pane leaves the pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
val top = MembyReleaseHistory.first().version
compose.onNodeWithTag("settings-release-$top").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-$top").assertIsNotFocused()
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
compose.onNodeWithTag("settings-rail-devices").assertIsFocused()
}
@Test
@@ -89,26 +65,6 @@ class SettingsRailFocusTest {
compose.onNodeWithTag("settings-rail-playback").assertIsFocused()
}
/** The pane's own vertical navigation is untouched by the escape above it. */
@Test
fun `down and up move between rows inside a pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
val releases = MembyReleaseHistory
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-${releases[1].version}").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-${releases[0].version}").assertIsFocused()
}
@Composable
private fun Fixture() {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
@@ -118,7 +74,6 @@ class SettingsRailFocusTest {
state = SettingsPanelState(
selectedPage = selectedPage,
installedVersion = "0.1.60",
releaseHistory = MembyReleaseHistory,
),
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
overlay = false,
@@ -19,7 +19,6 @@ import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.ui.PreviewSurface
import kotlinx.coroutines.delay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -155,30 +154,6 @@ class SettingsSheetScreenshotTest {
assertEquals(togglesByPage.values.flatten().toSet(), turnedOn)
}
@Test
fun `about lists the release history and opens one release`() {
compose.setContent { InteractiveSettingsFixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").performClick()
compose.waitForIdle()
val releases = MembyReleaseHistory
assertTrue("the shipped changelog parsed to nothing", releases.size >= 2)
compose.onNodeWithTag("settings-release-${releases[1].version}")
.performScrollTo()
.performClick()
compose.waitForIdle()
compose.onNodeWithText(releases[1].changes.first()).assertExists()
}
@Test
fun `about version history`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.ABOUT)
}
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
}
/**
* The panel wired to real state, so a press changes something a test can read. Every
* switch starts off: the assertion worth making is that each one can be turned *on*,