Release v0.2.39

This commit is contained in:
ponzischeme89
2026-08-09 16:57:14 +12:00
parent 52e167ea59
commit ed6743fd61
7 changed files with 206 additions and 3 deletions
+4
View File
@@ -1,3 +1,7 @@
## 0.2.39 — 2026-08-09
- Added: An optional confirmation before the Back button closes Memby.
- Improved: Reviewed D-pad navigation across the Android TV experience and documented follow-up work.
## 0.2.38 — 2026-08-09
- Improved: Ratings are larger and easier to read.
- Improved: Genre cards now use the same poster format as home-screen rows.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.38"
val defaultVersionName = "0.2.39"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -304,6 +304,9 @@ data class Settings(
val updateBaseUrl: String? = null,
val updateRepo: String? = null,
val updateToken: String? = null,
// Device-level: Back-button behaviour belongs to the television and its remote, not
// to the signed-in viewer, so it deliberately does not travel through UserPreferences.
val confirmExitMemby: Boolean = false,
// Show each item's Emby "Logo" image in place of the plain-text title.
val showTitleLogo: Boolean = true,
// Slide up a "next up" banner near the end of an episode and roll into the next one.
@@ -487,6 +490,7 @@ class SettingsStore(private val context: Context) {
val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
val UPDATE_REPO = stringPreferencesKey("update_repo")
val UPDATE_TOKEN = stringPreferencesKey("update_token")
val CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby")
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
@@ -570,6 +574,10 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setConfirmExitMemby(enabled: Boolean) {
context.dataStore.edit { it[Keys.CONFIRM_EXIT_MEMBY] = enabled }
}
/**
* Ids of alerts already shown on this TV. The gateway keeps offering an alert for as
* long as it is current, so without this an "aired" banner would return every poll —
@@ -1344,6 +1352,7 @@ class SettingsStore(private val context: Context) {
updateBaseUrl = preferences[Keys.UPDATE_BASE_URL],
updateRepo = preferences[Keys.UPDATE_REPO],
updateToken = preferences[Keys.UPDATE_TOKEN],
confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
@@ -232,6 +232,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
var initialUpdateCheckComplete by remember { mutableStateOf(false) }
var dismissedUpdateVersion by rememberSaveable { mutableStateOf<String?>(null) }
var confirmingExit by rememberSaveable { mutableStateOf(false) }
// SettingsStore starts eagerly in Application.onCreate. Reuse its in-memory value when
// Android recreates this activity after the viewer returns from the TV home screen;
// starting from null needlessly painted "Opening Memby..." while the replayed value
@@ -415,7 +416,9 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
!InstallPermission.granted(LocalContext.current) ->
InstallPermissionScreen(onContinue = { installPermissionHandled = true })
loaded.isSignedIn -> {
BackHandler(onBack = onCloseSettings)
BackHandler {
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
}
key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded)
}
@@ -446,6 +449,69 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
)
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
}
if (confirmingExit) {
ExitMembyConfirmation(
onStay = { confirmingExit = false },
onExit = onCloseSettings,
)
}
}
}
@Composable
private fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
BackHandler(onBack = onStay)
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(16L)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(480.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Close Memby?",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"Choose Stay to keep browsing, or close Memby and return to your TV.",
color = Color(0xFFBCC4CA),
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = onStay,
modifier = Modifier
.focusRequester(stayFocus)
.focusProperties { right = exitFocus },
) { Text("Stay in Memby") }
Button(
onClick = onExit,
modifier = Modifier
.focusRequester(exitFocus)
.focusProperties { left = stayFocus },
) { Text("Close Memby") }
}
}
}
}
@@ -208,6 +208,7 @@ internal data class SettingsPanelState(
val showCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val confirmExitMemby: Boolean = false,
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
/**
* The viewer's own colour scheme, and the ones the gateway says they may pick between.
@@ -264,6 +265,7 @@ internal data class SettingsPanelActions(
val onShowCardMetadataChanged: (Boolean) -> Unit = {},
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
val onConfirmExitMembyChanged: (Boolean) -> Unit = {},
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
val onThemeChanged: (String) -> Unit = {},
val onPageSelected: (SettingsPage) -> Unit = {},
@@ -312,6 +314,7 @@ fun SettingsSheet(
var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
var confirmExitMemby by rememberSaveable { mutableStateOf(settings.confirmExitMemby) }
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
// The resolved theme and the schemes on offer both come from the gateway, through the
// sync that owns them. Collected here rather than in SettingsPanelContent so the content
@@ -375,6 +378,7 @@ fun SettingsSheet(
settings.showHomeCardMetadata,
settings.showRatingsStrip,
settings.hideWatchedMovies,
settings.confirmExitMemby,
settings.autoPlayNextEpisode,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
@@ -395,6 +399,7 @@ fun SettingsSheet(
showCardMetadata = settings.showHomeCardMetadata
showRatingsStrip = settings.showRatingsStrip
hideWatchedMovies = settings.hideWatchedMovies
confirmExitMemby = settings.confirmExitMemby
welcomeQuoteStyle = settings.welcomeQuoteStyle
}
@@ -421,6 +426,7 @@ fun SettingsSheet(
showCardMetadata = showCardMetadata,
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
confirmExitMemby = confirmExitMemby,
welcomeQuoteStyle = welcomeQuoteStyle,
themeId = settings.themeId,
themeOptions = availableThemes.map { theme ->
@@ -510,6 +516,10 @@ fun SettingsSheet(
hideWatchedMovies = it
scope.launch { store.setHideWatchedMovies(it) }
},
onConfirmExitMembyChanged = {
confirmExitMemby = it
scope.launch { store.setConfirmExitMemby(it) }
},
onThemeChanged = { chosen ->
// No local echo: what is on screen is the palette the gateway resolves, and it
// arrives through ThemeSync a moment later. Painting optimistically here would
@@ -850,6 +860,13 @@ internal fun SettingsPanelContent(
checked = state.showRatingsStrip,
onCheckedChange = actions.onShowRatingsStripChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Confirm before closing Memby",
description = "Ask before the Back button closes the app.",
checked = state.confirmExitMemby,
onCheckedChange = actions.onConfirmExitMembyChanged,
)
if (state.hiddenHomeRowCount > 0) {
SettingDivider()
SettingsActionRow(
@@ -185,9 +185,17 @@ class UserPreferencesTest {
token = "session-token",
ringColorHex = "FF0000",
rotationIntervalSeconds = 45,
confirmExitMemby = true,
).toUserPreferences().encode().toString()
listOf("Living room", "device-1", "gitea-secret", "git.example", "session-token")
listOf(
"Living room",
"device-1",
"gitea-secret",
"git.example",
"session-token",
"confirmExitMemby",
)
.forEach { assertTrue("$it reached the wire", it !in encoded) }
}
}
+99
View File
@@ -0,0 +1,99 @@
# Android TV D-pad navigation audit — 9 August 2026
This audit reviews Memby as a ten-foot Android TV interface controlled with a directional
pad, centre/OK and Back. It focuses on whether every visible action can be reached, whether
focus moves predictably, and whether destructive or app-leaving actions are recoverable.
## Fixed in 0.2.39
### NAV-001 — Back closed Memby without warning
- Severity: High
- Surface: Home launcher
- Reproduction: Open Home with no panel or detail page visible, then press Back once.
- Previous behaviour: `MainActivity` finished immediately. An accidental Back press lost
the viewer's place and returned them to the Android TV launcher.
- Resolution: Added the optional **Confirm before closing Memby** setting. When enabled,
Back opens a two-button confirmation with **Stay in Memby** focused by default. Back also
dismisses the confirmation, and Left/Right is explicitly constrained between the two
choices. The setting defaults off and is stored per television.
## Open findings
### NAV-002 — Recommendation onboarding has no deterministic entry focus or Back route
- Severity: High
- Surface: First-time recommendation onboarding
- Reproduction: Sign in with a profile that has been prompted for taste onboarding.
- Finding: The screen creates focusable title/person cards and footer buttons but does not
request initial focus. It also has no `BackHandler`. Focus therefore depends on Compose's
geometric fallback, while Back can leave the activity instead of moving to a defined
previous step or presenting a skip choice.
- Recommended fix: Give the first card (or **Next** when the stage is empty) an explicit
entry requester. Make Back move to the previous stage; on the first stage, focus a clear
**Skip for now** confirmation instead of closing the app.
### NAV-003 — Manage users does not scale beyond one fixed row
- Severity: Medium
- Surface: Manage users / profile chooser
- Reproduction: Save enough profiles that the fixed 154 dp tiles plus 24 dp gaps exceed the
viewport width.
- Finding: Profiles are rendered in a plain `Row`, not a `LazyRow`, and do not wrap or
scroll. Profiles beyond the right edge can become invisible or unreachable by D-pad.
- Recommended fix: Use a centred `LazyRow` with content padding, stable keys and remembered
horizontal state. Keep **Add another user** as the final item.
### NAV-004 — Profile removal targets rely on geometric focus search
- Severity: Medium
- Surface: Manage users / profile chooser
- Reproduction: Move around a profile tile and its small × removal control using only the
D-pad.
- Finding: Each tile and its overlaid removal button are independent focus targets with no
explicit direction mapping. Depending on card position and screen density, Up/Right can
select a different profile or skip the removal target. The 28 dp target is also difficult
to identify at TV distance.
- Recommended fix: Put profile actions in a deterministic vertical group: OK opens the
profile and a labelled **Remove user** action sits below it, with explicit Up/Down and
Left/Right neighbours. Retain the existing safe Cancel-first confirmation.
### NAV-005 — Dismissing an alert can leave focus without a stable successor
- Severity: Medium
- Surface: My Alerts
- Reproduction: Focus an alert in the middle of the list and press OK to dismiss it.
- Finding: The focused keyed row is removed immediately, but focus is only requested when
the list changes between empty and non-empty. There is no requester for the next or
previous alert after an individual removal, so focus restoration is left to framework
behaviour and can appear to vanish on some Compose/TV combinations.
- Recommended fix: Track the focused alert id/index and, after removal, request the item now
occupying that index (or the previous item). When the list becomes empty, move focus to
the alert controls explicitly.
### NAV-006 — Onboarding stage changes do not preserve a clear focus destination
- Severity: Medium
- Surface: Recommendation onboarding
- Reproduction: Focus **Next**, advance a stage, then continue with the D-pad.
- Finding: The focused footer node is reused while the card row above is replaced. The
viewer is left at the bottom of each new question and must navigate geometrically back to
the new choices; there is no explicit Up target or per-stage return position.
- Recommended fix: On each stage change, move focus to the first choice and remember the
last focused choice per stage. Give the footer buttons explicit Up targets and the card
row an explicit Down target.
## Areas checked with no blocking issue found
- Main navigation rail: explicit content/rail focus requesters and section restoration.
- Home rows: explicit cross-row movement skips empty shelves and remembers card position.
- Search: defined keyboard/results entry points, staged Back behaviour and genre focus
restoration.
- Genre browser: explicit rail escape from the first grid column and Back closes one level.
- Settings: explicit secondary-rail directions, content entry and Cancel-first dialogs.
- Details and quick actions: Back closes one overlay level and restores the originating
card.
- Player: Back closes active overlays before leaving playback; centre, seek and menu keys
have deliberate remote handling.
- Update, maintenance and install-permission screens: explicit initial focus and bounded
Left/Right navigation.