diff --git a/CHANGELOG.md b/CHANGELOG.md index e199200..ce206b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b73dbc5..a1bc686 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index df2ac61..240e01c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index b4f5681..61a5bb3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -232,6 +232,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) { var appUpdate by remember { mutableStateOf(null) } var initialUpdateCheckComplete by remember { mutableStateOf(false) } var dismissedUpdateVersion by rememberSaveable { mutableStateOf(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") } + } + } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 711dd22..3aa9d6a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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( diff --git a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt index be30fe0..12c22a1 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt @@ -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) } } } diff --git a/bugs-0908.md b/bugs-0908.md new file mode 100644 index 0000000..b567c5f --- /dev/null +++ b/bugs-0908.md @@ -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.