Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
598a4f5c75 |
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user