Release v0.2.38
Publish Memby release / release (push) Canceled after 0s

This commit is contained in:
ponzischeme89
2026-08-09 16:04:40 +12:00
parent 0dffd59440
commit 52e167ea59
32 changed files with 442 additions and 472 deletions
@@ -0,0 +1,24 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
/**
* Loads the cast Emby associates with an item.
*
* Episodes commonly omit `People`, while their parent series holds the canonical cast.
* Keep episode-specific credits when Emby supplies them, and only pay for the series
* request when the episode has no cast of its own.
*/
internal suspend fun resolveCast(
itemId: String,
loadItem: suspend (String) -> BaseItem,
): List<EmbyPerson> {
val item = loadItem(itemId)
if (item.cast.isNotEmpty()) return item.cast
val seriesId = item.seriesId
?.takeIf(String::isNotBlank)
?.takeUnless { it == item.id }
return if (item.isEpisode && seriesId != null) loadItem(seriesId).cast else emptyList()
}
@@ -91,6 +91,8 @@ class MaintenanceMonitor(
private val _preferencesRevision = MutableStateFlow(0L)
private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _installPermissionPrompt = MutableStateFlow(false)
private val _genreBrowserEnabled = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("")
/**
* Which colour scheme this viewer's televisions should be painted, as an id and a
@@ -118,6 +120,12 @@ class MaintenanceMonitor(
*/
val installPermissionPrompt: StateFlow<Boolean> = _installPermissionPrompt.asStateFlow()
/** Server-controlled because the browser layout is still being refined. */
val genreBrowserEnabled: StateFlow<Boolean> = _genreBrowserEnabled.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
private val seenAlertIds = mutableSetOf<String>()
private var seenAlertsLoaded = false
private var shownAlertId: String? = null
@@ -173,6 +181,8 @@ class MaintenanceMonitor(
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_gatewayVersion.value = ""
dismissAlert()
return@collectLatest
}
@@ -202,6 +212,8 @@ class MaintenanceMonitor(
_theme.value = status.theme
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
_gatewayVersion.value = status.gatewayVersion
// Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll.
@@ -223,6 +235,8 @@ class MaintenanceMonitor(
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_gatewayVersion.value = ""
dismissAlert()
return@collectLatest
}
@@ -323,6 +337,9 @@ class MaintenanceMonitor(
/** Matches `featureInstallPermission` in the gateway's feature catalogue. */
internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt"
/** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
internal const val GENRE_BROWSER_FEATURE = "genre_browser"
/**
* How often Emby is retried during an outage. It matches the gateway's own
* default (MEMBY_EMBY_HEALTH_INTERVAL), so the countdown on the bar means the
@@ -148,8 +148,17 @@ class PreferencesSync(
if (decoded == trigger.local) {
runCatching { settings.setPreferencesRevision(stored.revision) }
} else {
runCatching { settings.applyRemotePreferences(decoded, stored.revision) }
.onFailure { lastSynced = null }
// The request may have been in flight while the viewer made a newer choice.
// A conflict describes the document that won against the request, not against
// an edit made after it. Advance that newer edit to the winning revision and
// let the queued trigger push it, instead of replacing it with stale values.
val current = runCatching { settings.snapshot().toUserPreferences() }.getOrNull()
if (current != null && current != trigger.local) {
runCatching { settings.setPreferencesRevision(stored.revision) }
} else {
runCatching { settings.applyRemotePreferences(decoded, stored.revision) }
.onFailure { lastSynced = null }
}
}
}
}
@@ -376,10 +376,10 @@ data class Settings(
*/
val onboardedUserIds: Set<String> = emptySet(),
/**
* The app version whose release notes this television has already shown. Deliberately
* device state rather than a synced preference: what is new is a property of the APK
* sitting on this set, and a viewer who signs into a second TV that is still a version
* behind has not seen that build's notes.
* The app version this television has already announced. Deliberately device state
* rather than a synced preference: what is new is a property of the APK sitting on
* this set, and a viewer who signs into a second TV that is still a version behind has
* not seen that build's update notice.
*/
val whatsNewSeenVersion: String? = null,
/**
@@ -968,10 +968,9 @@ class SettingsStore(private val context: Context) {
}
/**
* Records the version whose release notes have been shown on this television, so the
* "what's new" panel appears exactly once per update. Written on dismissal, and also
* written silently on a fresh install so the first sign-in is not greeted by notes for
* a build the viewer has never been without.
* Records the version announced on this television, so the update toast appears exactly
* once. Also written silently on a fresh install, which has not updated from an earlier
* build and therefore has nothing to announce.
*/
suspend fun markWhatsNewSeen(version: String) {
val trimmed = version.trim()
@@ -119,6 +119,7 @@ data class GatewayServiceStatus(
val compatibilityMessage: String = "",
val clientVersion: String = "",
val clientProtocol: String = "",
val gatewayVersion: String = "",
val serverProtocol: Int = 0,
val featureSchemaVersion: Int = 0,
val featureRevision: Long = 0,
@@ -74,6 +74,7 @@ internal val MEMBY_CAPABILITIES = listOf(
"live_feature_refresh_v1",
"sonarr_preroll_v1",
"auto_my_shows_v1",
"genre_browser_v1",
// Declares that this build can show the install-permission step. An older app never
// receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1",
@@ -234,6 +234,7 @@ fun TvNavigationRail(
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
alertCount: Int = 0,
activeUsername: String = "",
) {
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
@@ -246,10 +247,8 @@ fun TvNavigationRail(
}
}
LaunchedEffect(selected) {
if (selected == BrowseDestination.HOME) {
logoRotation.snapTo(0f)
logoRotation.animateTo(360f, tween(850))
}
logoRotation.snapTo(0f)
logoRotation.animateTo(360f, tween(850))
}
LaunchedEffect(railHasFocus) {
if (railHasFocus) {
@@ -355,6 +354,9 @@ fun TvNavigationRail(
} else {
null
},
avatarInitials = activeUsername.takeIf {
destination == BrowseDestination.PROFILES
}?.let(::profileInitials),
)
Spacer(Modifier.height(4.dp))
}
@@ -567,7 +569,7 @@ private fun UserSwitcherProfileItem(
contentAlignment = Alignment.Center,
) {
Text(
profile.username.trim().firstOrNull()?.uppercase() ?: "?",
profileInitials(profile.username),
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
@@ -656,6 +658,7 @@ fun ExpandableNavigationItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: String? = null,
avatarInitials: String? = null,
) {
var focused by remember { mutableStateOf(false) }
// Not `by`: both colours are read in the draw phase / at the point of use, so the
@@ -698,7 +701,26 @@ fun ExpandableNavigationItem(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) {
Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
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, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
}
if (selected) {
Box(
Modifier
@@ -735,6 +757,22 @@ fun ExpandableNavigationItem(
}
}
/** Two readable initials for compact profile avatars: AlwynV → AV, Matt Cohen → MC. */
internal fun profileInitials(username: String): String {
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()
}
}
@Composable
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current
@@ -150,6 +150,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// when the server rejects it, without refreshing or losing the active category.
private val _favoriteChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
private val _playedChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val playedChanges: StateFlow<Map<String, Boolean>> = _playedChanges.asStateFlow()
private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null
@@ -417,6 +419,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
fun setPlayed(item: BaseItem, played: Boolean) {
val previousPlayed = item.userData?.played == true
val previousPosition = item.userData?.playbackPositionTicks ?: 0L
_playedChanges.update { it + (item.id to played) }
updateUserData(item.id) {
it.copy(
played = played,
@@ -426,10 +431,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.setPlayed(item.id, played) }
.onSuccess { confirmed ->
updateUserData(item.id) { it.copy(played = confirmed) }
_playedChanges.update { it + (item.id to confirmed) }
updateUserData(item.id) {
it.copy(
played = confirmed,
playbackPositionTicks = if (confirmed) 0L else it.playbackPositionTicks,
)
}
}
.onFailure {
updateUserData(item.id) { it.copy(played = !played) }
_playedChanges.update { it + (item.id to previousPlayed) }
updateUserData(item.id) {
it.copy(played = previousPlayed, playbackPositionTicks = previousPosition)
}
}
}
}
@@ -138,12 +138,9 @@ import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.ui.search.SearchScreen
import com.ponzischeme89.memby.ui.settings.MembyReleaseHistory
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import com.ponzischeme89.memby.ui.settings.SettingsSheet
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewOverlay
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
@@ -228,7 +225,7 @@ private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
@Composable
private fun AppRoot(onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository
val scope = rememberCoroutineScope()
val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active
// profile. Updates are an app lifecycle concern, checked before login/session work.
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
@@ -257,9 +254,6 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
)
}
var onboardingToken by remember { mutableStateOf<String?>(null) }
// The release notes for the build that has just been installed, or null. Resolved from
// disk only — see whatsNewDecision — so it can never delay the launcher.
var whatsNew by remember { mutableStateOf<ReleaseNote?>(null) }
LaunchedEffect(updateService) {
var firstCheck = true
while (true) {
@@ -346,24 +340,28 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true)
}
// Whether this TV has already been told what changed in the build it is running. Keyed
// Whether this TV has already been told about the build it is running. Keyed
// on the recorded version and the session, because a fresh install records the current
// version during setup and a sign-in is what turns "waiting" into "show it now".
// version during setup and a sign-in is what turns "waiting" into "announce it now".
LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) {
val loaded = settings ?: return@LaunchedEffect
val decision = whatsNewDecision(
installedVersion = BuildConfig.VERSION_NAME,
seenVersion = loaded.whatsNewSeenVersion,
isSignedIn = loaded.isSignedIn,
releases = MembyReleaseHistory,
)
when (decision) {
is WhatsNewDecision.Show -> whatsNew = decision.release
// Recorded without being shown, which is also what retires the panel after the
// viewer dismisses it: the settings flow re-emits and this lands on Nothing.
is WhatsNewDecision.Notify -> {
Toast.makeText(
context,
context.getString(R.string.app_updated_to_version, decision.version),
Toast.LENGTH_LONG,
).show()
ServiceLocator.settings.markWhatsNewSeen(decision.version)
}
is WhatsNewDecision.MarkSeen ->
ServiceLocator.settings.markWhatsNewSeen(decision.version)
WhatsNewDecision.Nothing -> whatsNew = null
WhatsNewDecision.Nothing -> Unit
}
}
@@ -431,20 +429,6 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
decoration = ServiceLocator.themeSync.theme
.collectAsState().value?.decoration.orEmpty(),
)
// Over the launcher, not instead of it: the cached rows are already drawn
// behind this. Composed after HomeScreen so its Back handler and its focus
// request are the ones that win.
whatsNew?.let { release ->
WhatsNewOverlay(
release = release,
onDismiss = {
whatsNew = null
scope.launch {
ServiceLocator.settings.markWhatsNewSeen(release.version)
}
},
)
}
}
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
settings = loaded,
@@ -1355,8 +1339,10 @@ private fun HomeScreen(
val homeStatus by homeViewModel.status.collectAsStateWithLifecycle()
val forYouState by homeViewModel.forYou.collectAsStateWithLifecycle()
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
// Only whether there is one, not its countdown — that is collected inside the banner
// so a ticking second never reaches the launcher. This is read here purely to decide
// which of the two top bars gets the strip.
@@ -1374,6 +1360,12 @@ private fun HomeScreen(
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(genreBrowserEnabled) {
if (!genreBrowserEnabled) {
genreBrowseItemType = null
genreBrowseInitialCategoryId = null
}
}
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
var restoreRailAfterSettings by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
@@ -1699,6 +1691,7 @@ private fun HomeScreen(
}
},
alertCount = notificationState.notifications.size,
activeUsername = settings.username.orEmpty(),
)
androidx.compose.foundation.layout.BoxWithConstraints(
modifier = Modifier
@@ -1773,6 +1766,7 @@ private fun HomeScreen(
initialCategoryId = genreBrowseInitialCategoryId
?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
favouriteStates = favoriteChanges,
playedStates = playedChanges,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
@@ -1930,8 +1924,9 @@ private fun HomeScreen(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (
selectedDestination == BrowseDestination.MOVIES ||
selectedDestination == BrowseDestination.SHOWS
genreBrowserEnabled &&
(selectedDestination == BrowseDestination.MOVIES ||
selectedDestination == BrowseDestination.SHOWS)
) {
item(key = "genre-browser", contentType = "genre-browser") {
val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
@@ -1955,9 +1950,12 @@ private fun HomeScreen(
availableWidth = contentWidth,
density = settings.homeCardDensity,
navigationFocusRequester = navigationFocusRequester,
// The Genres launcher above is the destination's entry
// target. One requester cannot be attached to both.
contentFocusRequester = null,
// The genre launcher owns the entry target only while
// the server has enabled it. When it is off, My Shows
// becomes the first reachable content on this page.
contentFocusRequester = contentFocusRequester.takeUnless {
genreBrowserEnabled
},
onShowSelected = { selectedMyShow = it },
onContentFocused = { navigationExpanded = false },
)
@@ -2015,8 +2013,10 @@ private fun HomeScreen(
navigationFocusRequester = navigationFocusRequester,
contentEntryFocusRequester = contentFocusRequester.takeIf {
!hasHomeHero &&
selectedDestination != BrowseDestination.MOVIES &&
selectedDestination != BrowseDestination.SHOWS &&
(selectedDestination != BrowseDestination.SHOWS ||
(!genreBrowserEnabled && myShows.isEmpty())) &&
(selectedDestination != BrowseDestination.MOVIES ||
!genreBrowserEnabled) &&
row.id == firstPopulatedRowId
},
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
@@ -45,9 +45,9 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyRatingsSurface
import kotlin.math.roundToInt
private val RatingStripHeight = 27.dp
private val RatingMarkHeight = 15.dp
private val RatingMarkHeightCompact = 13.dp
private val RatingStripHeight = 34.dp
private val RatingMarkHeight = 19.dp
private val RatingMarkHeightCompact = 16.dp
/** One non-focusable, layout-stable presentation for ratings everywhere in the app. */
@Composable
@@ -73,13 +73,13 @@ fun RatingsStrip(
contentAlignment = Alignment.CenterStart,
) {
// The responsive limit is based on the usable width inside the capsule.
val contentWidth = (maxWidth.value.roundToInt() - 20).coerceAtLeast(0)
val contentWidth = (maxWidth.value.roundToInt() - 24).coerceAtLeast(0)
val shown = valid.take(ratingDisplayLimit(contentWidth))
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(MembyRatingsSurface)
.padding(horizontal = 10.dp, vertical = 4.dp),
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 13.dp),
) {
@@ -92,7 +92,7 @@ fun RatingsStrip(
Text(
rating.formattedScore(),
color = MembyOnSurface,
fontSize = if (compact) 11.sp else 13.sp,
fontSize = if (compact) 13.sp else 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
@@ -160,7 +160,7 @@ private fun RatingMark(rating: MediaRating, compact: Boolean) {
Text(
rating.wordmark(),
color = providerColor(rating.source),
fontSize = if (compact) 10.sp else 12.sp,
fontSize = if (compact) 12.sp else 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Clip,
@@ -6,6 +6,7 @@ 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.aspectRatio
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -71,6 +72,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
@@ -124,35 +126,36 @@ private fun GenreDiscoveryCard(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Browse ${category.label}",
modifier = modifier
.width(184.dp)
.height(92.dp)
.background(visual.colour, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(12.dp)),
modifier = modifier.width(142.dp),
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(
if (focused) Color.White.copy(alpha = 0.16f) else Color.Transparent,
RoundedCornerShape(12.dp),
Column {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.background(visual.colour, RoundedCornerShape(MembyCardCorner))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
RoundedCornerShape(MembyCardCorner),
),
contentAlignment = Alignment.Center,
) {
Icon(
visual.icon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(48.dp),
)
.padding(14.dp),
) {
}
Spacer(Modifier.height(8.dp))
Text(
category.label,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
lineHeight = 19.sp,
maxLines = 2,
modifier = Modifier.align(Alignment.BottomStart).width(120.dp),
)
Icon(
visual.icon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.align(Alignment.TopEnd).size(32.dp),
color = if (focused) Color.White else MembyMutedText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -185,6 +188,7 @@ fun GenreBrowseScreen(
itemType: String,
initialCategoryId: String,
favouriteStates: Map<String, Boolean>,
playedStates: Map<String, Boolean>,
navigationFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
returnFocusItemId: String?,
@@ -210,6 +214,7 @@ fun GenreBrowseScreen(
LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) }
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
LaunchedEffect(state.selectedCategoryId) {
val index = state.categories.indexOfFirst { it.id == state.selectedCategoryId }.coerceAtLeast(0)
if (state.selectedCategoryId != null) {
@@ -100,6 +100,28 @@ class GenreBrowseViewModel(
}
}
/** Applies optimistic watched changes to the paged copies without losing position. */
fun applyPlayedStates(changes: Map<String, Boolean>) {
if (changes.isEmpty()) return
categoryPages.entries.forEach { entry ->
entry.setValue(
entry.value.copy(
items = entry.value.items.map { item ->
changes[item.id]?.let { item.withPlayed(it) } ?: item
},
),
)
}
_state.update { current ->
current.copy(
items = current.items.map { item ->
val played = changes[item.id] ?: return@map item
item.withPlayed(played)
},
)
}
}
private suspend fun loadPage(category: GenreCategory, offset: Int) {
runCatching {
if (category.genres.isEmpty()) {
@@ -102,3 +102,10 @@ fun allMediaLabel(itemType: String): String =
fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy(
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
)
fun BaseItem.withPlayed(played: Boolean): BaseItem = copy(
userData = (userData ?: UserItemData()).copy(
played = played,
playbackPositionTicks = if (played) 0L else userData?.playbackPositionTicks ?: 0L,
),
)
@@ -73,6 +73,7 @@ import com.ponzischeme89.memby.data.SUBTITLE_LANGUAGE_AUTO
import com.ponzischeme89.memby.data.SubtitleCandidate
import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast
import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
@@ -2972,7 +2973,8 @@ class PlayerActivity : ComponentActivity() {
}
castJob = lifecycleScope.launch {
val loaded = runCatching {
ServiceLocator.repository.getItemDetails(requestedItemId).cast.take(MAX_CAST_MEMBERS)
resolveCast(requestedItemId, ServiceLocator.repository::getItemDetails)
.take(MAX_CAST_MEMBERS)
}.getOrDefault(emptyList())
if (itemId == requestedItemId) {
castPeople = loaded
@@ -229,6 +229,7 @@ internal data class SettingsPanelState(
val themeNotice: String = "",
val selectedPage: SettingsPage = SettingsPage.APPEARANCE,
val installedVersion: String = "",
val gatewayVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(),
val devicesLoading: Boolean = false,
@@ -317,6 +318,7 @@ fun SettingsSheet(
// stays parameter-driven and screenshot-testable with no server.
val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState()
val availableThemes by ServiceLocator.themeSync.available.collectAsState()
val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsState()
var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
var devicesLoading by remember { mutableStateOf(false) }
@@ -430,6 +432,7 @@ fun SettingsSheet(
themeNotice = resolvedTheme?.takeIf { it.locked }?.reason.orEmpty(),
selectedPage = selectedPage,
installedVersion = checker.installedVersion,
gatewayVersion = gatewayVersion,
devices = devices,
devicesLoading = devicesLoading,
devicesError = devicesError,
@@ -938,6 +941,11 @@ internal fun SettingsPanelContent(
fontWeight = FontWeight.Bold,
)
}
SettingDivider()
VersionRow(
label = "Memby gateway",
version = state.gatewayVersion.takeIf(String::isNotBlank) ?: "Not connected",
)
}
}
if (state.selectedPage == SettingsPage.ABOUT) {
@@ -6,12 +6,10 @@
package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.settings.ReleaseNote
/** What a launch should do about the release notes for the build that is running. */
/** What a launch should do about the update notice for the build that is running. */
internal sealed interface WhatsNewDecision {
/** Show these notes, then record the version once the viewer dismisses them. */
data class Show(val release: ReleaseNote) : WhatsNewDecision
/** Briefly announce this installed version, then record it. */
data class Notify(val version: String) : WhatsNewDecision
/** Record the version without showing anything. */
data class MarkSeen(val version: String) : WhatsNewDecision
@@ -21,19 +19,18 @@ internal sealed interface WhatsNewDecision {
}
/**
* Decides whether a television that has just been updated should be told what changed.
* Decides whether a television that has just been updated should announce the new version.
*
* Pure, because the interesting part is not the panel but which launches it must stay out
* of. Three cases are deliberately not "show":
* Pure, because the interesting part is which launches the toast must stay out of. Three
* cases are deliberately not "notify":
*
* - **Already recorded.** The whole contract is once per update; every later launch of the
* same build is silent, which is what [seenVersion] exists for.
* - **A fresh install** (no record at all, and nobody signed in yet). Everything in this
* build is new to that TV, so a list of changes since a version it never ran is noise at
* the worst moment. It is marked seen during setup so the first launcher is clean.
* - **A build the changelog does not describe.** An unreleased or locally-built version has
* no entry, and an empty panel is worse than none it is marked seen instead, so the
* next real update still lands.
* - **A fresh install** (no record at all, and nobody signed in yet). It has not updated
* from an earlier version, so it is marked seen during setup and the first launcher stays
* quiet.
* - **Signed out.** A toast belongs over the launcher, so an updated television with an
* existing record waits until somebody signs in rather than consuming the notice early.
*
* Signed out with a record already present is [Nothing] rather than [MarkSeen]: the notes
* belong over the launcher, so that launch simply waits for whoever is about to sign in.
@@ -42,19 +39,11 @@ internal fun whatsNewDecision(
installedVersion: String,
seenVersion: String?,
isSignedIn: Boolean,
releases: List<ReleaseNote>,
): WhatsNewDecision {
val version = installedVersion.trim()
if (version.isEmpty()) return WhatsNewDecision.Nothing
if (seenVersion?.trim() == version) return WhatsNewDecision.Nothing
if (seenVersion == null && !isSignedIn) return WhatsNewDecision.MarkSeen(version)
if (!isSignedIn) return WhatsNewDecision.Nothing
val release = releases.firstOrNull { it.version == version && it.changes.isNotEmpty() }
?: return WhatsNewDecision.MarkSeen(version)
return WhatsNewDecision.Show(release)
return WhatsNewDecision.Notify(version)
}
// A bullet's leading "Fixed:" / "Added:" used to be split off into an accent pill. Both
// places a viewer reads release notes — this panel and Settings → About — now render the
// line the changelog was written with, so there is nothing left to parse. See
// WhatsNewOverlay's ChangeRow for why.
@@ -1,226 +0,0 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.whatsnew
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.BoxWithConstraints
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
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.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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import com.ponzischeme89.memby.ui.theme.MembyAccent
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.MembySurfaceRaised
import kotlinx.coroutines.delay
/**
* The most bullets the panel will print even on a tall screen. A release with more is not
* losing anything the full history is in Settings About, which the closing line points
* at but past half a dozen the panel stops being a note and becomes a document.
*/
private const val MAX_CHANGES = 6
/** Everything above and below the bullets: eyebrow, version, date, button and padding. */
private const val CHROME_HEIGHT_DP = 320
/** One bullet's budget, allowing for a sentence that wraps onto a second line. */
private const val CHANGE_ROW_HEIGHT_DP = 52
/**
* How many bullets fit on this screen. Derived rather than fixed, for the same reason the
* detail pages derive their pane height: a panel that runs off the bottom of a 720p set has
* no scrollbar to aim at and no way for the remote to reach the button under it. If a
* release needs more room than the screen has, it loses bullets never the button.
*/
internal fun maxChangesFor(availableHeightDp: Int): Int =
((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES)
private val Scrim = Color(0xE60A0C0F)
/**
* What changed in the build that has just been installed, shown once over the launcher.
*
* Deliberately an overlay rather than a screen: the rows are already drawn behind it from
* the home cache, so the viewer sees their television working and this as a note on top of
* it, not another wait before it. Stateless [whatsNewDecision] decides whether it appears
* and `SettingsStore.markWhatsNewSeen` records that it did so it can be screenshotted
* with no store, no gateway and no session.
*/
@Composable
internal fun WhatsNewOverlay(
release: ReleaseNote,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val dismissFocus = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
// The launcher requests focus for its first row on the same frame, and on a slow set
// that request can land after this one. Ask again until it sticks rather than leaving a
// panel on screen with the remote still driving the rows behind it.
LaunchedEffect(release.version) {
repeat(6) {
if (focused) return@LaunchedEffect
runCatching { dismissFocus.requestFocus() }
delay(100)
}
}
BackHandler(enabled = true, onBack = onDismiss)
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { entered = true }
val entrance by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(280),
label = "whats-new-entrance",
)
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.zIndex(8f)
.background(Scrim)
.testTag("whats-new"),
contentAlignment = Alignment.Center,
) {
val shown = maxChangesFor(maxHeight.value.toInt())
Column(
modifier = Modifier
.graphicsLayer {
alpha = entrance
translationY = (1f - entrance) * 18.dp.toPx()
}
.widthIn(max = 760.dp)
.clip(RoundedCornerShape(MembyPanelCorner))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 40.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"WHAT'S NEW",
color = MembyAccent,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(10.dp))
Text(
"Memby ${release.version}",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
if (release.date.isNotBlank()) {
Spacer(Modifier.height(6.dp))
Text("Installed on this TV · ${release.date}", color = MembyQuietText, fontSize = 14.sp)
}
Spacer(Modifier.height(22.dp))
Column(
modifier = Modifier.widthIn(max = 620.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
release.changes.take(shown).forEach { ChangeRow(it) }
}
if (release.changes.size > shown) {
Spacer(Modifier.height(16.dp))
Text(
"And more — the full history is in Settings → About.",
color = MembyQuietText,
fontSize = 14.sp,
)
}
Spacer(Modifier.height(28.dp))
UpdateButton(
label = "Continue",
primary = true,
enabled = true,
onClick = onDismiss,
modifier = Modifier
.focusRequester(dismissFocus)
// The only focusable thing on screen, over a launcher full of them.
// Every direction points back here, or one press of Down walks into
// rows the viewer cannot see behind the scrim.
.focusProperties {
up = dismissFocus
down = dismissFocus
left = dismissFocus
right = dismissFocus
}
.onFocusChanged { focused = it.isFocused }
.testTag("whats-new-continue"),
)
}
}
}
/**
* One bullet, as the sentence the changelog was written with and nothing else.
*
* It used to lift a leading "Fixed:" / "Added:" into an accent pill in a fixed 88dp column.
* Two things were wrong with that. The column was sized for the longest label, so a panel of
* three one-word tags spent a fifth of its width on them and every sentence started an inch
* from the margin; and the pills were the brightest thing on a screen whose job is to be
* read, so the eye went to four repetitions of the word FIXED rather than to what had been.
* The full line is shown instead which is also exactly what Settings About renders, so
* the two places a viewer reads release notes now agree.
*/
@Composable
private fun ChangeRow(change: String) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.padding(top = 8.dp)
.size(6.dp)
.clip(CircleShape)
.background(MembyAccent.copy(alpha = 0.7f)),
)
Text(change, color = MembyMutedText, fontSize = 16.sp)
}
}
+1
View File
@@ -87,6 +87,7 @@
<string name="end_credits_speed">%1$s speed</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="developer_name">ponzischeme89</string>
</resources>
@@ -2,12 +2,63 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class CastMetadataTest {
@Test
fun `episode without cast falls back to its series cast`() = runTest {
val seriesCast = listOf(actor("series-lead"))
val items = mapOf(
"episode" to BaseItem(id = "episode", type = "Episode", seriesId = "series"),
"series" to BaseItem(id = "series", type = "Series", people = seriesCast),
)
val requested = mutableListOf<String>()
val result = resolveCast("episode") { id ->
requested += id
requireNotNull(items[id])
}
assertEquals(seriesCast, result)
assertEquals(listOf("episode", "series"), requested)
}
@Test
fun `episode cast takes precedence over series cast`() = runTest {
val guestCast = listOf(actor("guest"))
val requested = mutableListOf<String>()
val result = resolveCast("episode") { id ->
requested += id
BaseItem(
id = id,
type = "Episode",
seriesId = "series",
people = guestCast,
)
}
assertEquals(guestCast, result)
assertEquals(listOf("episode"), requested)
}
@Test
fun `non-episode without cast does not use a series fallback`() = runTest {
val requested = mutableListOf<String>()
val result = resolveCast("movie") { id ->
requested += id
BaseItem(id = id, type = "Movie", seriesId = "series")
}
assertEquals(emptyList<EmbyPerson>(), result)
assertEquals(listOf("movie"), requested)
}
@Test
fun `cast contains actors and preserves Emby order`() {
val item = BaseItem(
@@ -45,4 +96,6 @@ class CastMetadataTest {
assertEquals("person-1", item.cast.single().id)
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
}
private fun actor(id: String) = EmbyPerson(id = id, name = id, type = "Actor")
}
@@ -118,11 +118,12 @@ 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","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}""",
)
assertEquals(false, status.compatible)
assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
assertEquals("0.1.29", status.gatewayVersion)
assertEquals(1, status.serverProtocol)
}
@@ -0,0 +1,19 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class ProfileInitialsTest {
@Test
fun `uses camel case capitals for compact usernames`() {
assertEquals("AV", profileInitials("AlwynV"))
assertEquals("MC", profileInitials("MattC"))
}
@Test
fun `uses word initials and safe short fallbacks`() {
assertEquals("MC", profileInitials("Matt Cohen"))
assertEquals("AL", profileInitials("alwyn"))
assertEquals("?", profileInitials(" "))
}
}
@@ -58,4 +58,12 @@ class GenreBrowseTest {
assertTrue(item.withFavourite(true).isFavorite)
assertFalse(item.withFavourite(true).withFavourite(false).isFavorite)
}
@Test
fun `watched overrides update a paged poster immediately`() {
val item = BaseItem(id = "film", name = "Film", type = "Movie")
assertFalse(item.userData?.played == true)
assertTrue(item.withPlayed(true).userData?.played == true)
assertFalse(item.withPlayed(true).withPlayed(false).userData?.played == true)
}
}
@@ -1,64 +0,0 @@
package com.ponzischeme89.memby.ui.whatsnew
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The panel a television shows once, after it has updated itself overnight. Nobody sees it
* twice, so being able to look at it without reinstalling on hardware is the point.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class WhatsNewScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `a typical release`() {
compose.setContent {
PreviewSurface {
WhatsNewOverlay(
release = ReleaseNote(
version = "0.2.24",
date = "2026-08-05",
changes = listOf(
"Fixed: The centre button on the remote now pauses straight away.",
"Changed: Subtitles now open in a small menu above the button.",
"Added: Continuing and ended tags on My Shows.",
),
),
onDismiss = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/whats-new/whats-new.png")
}
@Test
fun `a long release is capped`() {
compose.setContent {
PreviewSurface {
WhatsNewOverlay(
release = ReleaseNote(
version = "0.2.24",
date = "2026-08-05",
changes = List(9) { "Fixed: Change number ${it + 1} in a long release." },
),
onDismiss = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/whats-new/whats-new-long.png")
}
}
@@ -1,26 +1,19 @@
package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import org.junit.Assert.assertEquals
import org.junit.Test
class WhatsNewTest {
private val history = listOf(
ReleaseNote("0.2.24", "2026-08-05", listOf("Fixed: A thing.", "Added: Another thing.")),
ReleaseNote("0.2.23", "2026-08-04", listOf("Added: An older thing.")),
)
private fun decide(
installed: String = "0.2.24",
seen: String? = "0.2.23",
signedIn: Boolean = true,
releases: List<ReleaseNote> = history,
) = whatsNewDecision(installed, seen, signedIn, releases)
) = whatsNewDecision(installed, seen, signedIn)
@Test
fun `an updated television is shown the notes for the build it is running`() {
assertEquals(WhatsNewDecision.Show(history[0]), decide())
fun `an updated television is notified of the build it is running`() {
assertEquals(WhatsNewDecision.Notify("0.2.24"), decide())
}
@Test
@@ -38,23 +31,17 @@ class WhatsNewTest {
@Test
fun `an install that predates the record is announced once it is signed in`() {
assertEquals(WhatsNewDecision.Show(history[0]), decide(seen = null, signedIn = true))
assertEquals(WhatsNewDecision.Notify("0.2.24"), decide(seen = null, signedIn = true))
}
@Test
fun `a signed-out television with a record waits rather than burning the notes`() {
fun `a signed-out television with a record waits rather than consuming the notice`() {
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.23", signedIn = false))
}
@Test
fun `a build the changelog does not describe is recorded silently`() {
assertEquals(WhatsNewDecision.MarkSeen("0.9.0"), decide(installed = "0.9.0"))
}
@Test
fun `a release with no bullets is not shown as an empty panel`() {
val empty = listOf(ReleaseNote("0.2.24", "2026-08-05", emptyList()))
assertEquals(WhatsNewDecision.MarkSeen("0.2.24"), decide(releases = empty))
fun `an updated build does not depend on a changelog entry`() {
assertEquals(WhatsNewDecision.Notify("0.9.0"), decide(installed = "0.9.0"))
}
@Test
@@ -62,13 +49,4 @@ class WhatsNewTest {
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
}
@Test
fun `the bullet count is cut to what the screen can hold`() {
// A 720p television, which is the small case this exists for.
assertEquals(4, maxChangesFor(540))
// Room for more does not mean an unbounded list.
assertEquals(6, maxChangesFor(1200))
// Never zero: a panel with a heading and nothing under it says nothing at all.
assertEquals(1, maxChangesFor(200))
}
}