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
|
## 0.2.38 — 2026-08-09
|
||||||
- Improved: Ratings are larger and easier to read.
|
- Improved: Ratings are larger and easier to read.
|
||||||
- Improved: Genre cards now use the same poster format as home-screen rows.
|
- 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
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.38"
|
val defaultVersionName = "0.2.39"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -304,6 +304,9 @@ data class Settings(
|
|||||||
val updateBaseUrl: String? = null,
|
val updateBaseUrl: String? = null,
|
||||||
val updateRepo: String? = null,
|
val updateRepo: String? = null,
|
||||||
val updateToken: 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.
|
// Show each item's Emby "Logo" image in place of the plain-text title.
|
||||||
val showTitleLogo: Boolean = true,
|
val showTitleLogo: Boolean = true,
|
||||||
// Slide up a "next up" banner near the end of an episode and roll into the next one.
|
// 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_BASE_URL = stringPreferencesKey("update_base_url")
|
||||||
val UPDATE_REPO = stringPreferencesKey("update_repo")
|
val UPDATE_REPO = stringPreferencesKey("update_repo")
|
||||||
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
||||||
|
val CONFIRM_EXIT_MEMBY = booleanPreferencesKey("confirm_exit_memby")
|
||||||
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
||||||
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
|
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
|
||||||
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
|
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
|
* 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 —
|
* 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],
|
updateBaseUrl = preferences[Keys.UPDATE_BASE_URL],
|
||||||
updateRepo = preferences[Keys.UPDATE_REPO],
|
updateRepo = preferences[Keys.UPDATE_REPO],
|
||||||
updateToken = preferences[Keys.UPDATE_TOKEN],
|
updateToken = preferences[Keys.UPDATE_TOKEN],
|
||||||
|
confirmExitMemby = preferences[Keys.CONFIRM_EXIT_MEMBY] ?: false,
|
||||||
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||||
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
|
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
|
||||||
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: 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 appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
|
||||||
var initialUpdateCheckComplete by remember { mutableStateOf(false) }
|
var initialUpdateCheckComplete by remember { mutableStateOf(false) }
|
||||||
var dismissedUpdateVersion by rememberSaveable { mutableStateOf<String?>(null) }
|
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
|
// 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;
|
// Android recreates this activity after the viewer returns from the TV home screen;
|
||||||
// starting from null needlessly painted "Opening Memby..." while the replayed value
|
// starting from null needlessly painted "Opening Memby..." while the replayed value
|
||||||
@@ -415,7 +416,9 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
|||||||
!InstallPermission.granted(LocalContext.current) ->
|
!InstallPermission.granted(LocalContext.current) ->
|
||||||
InstallPermissionScreen(onContinue = { installPermissionHandled = true })
|
InstallPermissionScreen(onContinue = { installPermissionHandled = true })
|
||||||
loaded.isSignedIn -> {
|
loaded.isSignedIn -> {
|
||||||
BackHandler(onBack = onCloseSettings)
|
BackHandler {
|
||||||
|
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
|
||||||
|
}
|
||||||
key(loaded.userId, loaded.serverUrl) {
|
key(loaded.userId, loaded.serverUrl) {
|
||||||
HomeScreen(settings = loaded)
|
HomeScreen(settings = loaded)
|
||||||
}
|
}
|
||||||
@@ -446,6 +449,69 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
|||||||
)
|
)
|
||||||
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
|
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 showCardMetadata: Boolean = true,
|
||||||
val showRatingsStrip: Boolean = true,
|
val showRatingsStrip: Boolean = true,
|
||||||
val hideWatchedMovies: Boolean = false,
|
val hideWatchedMovies: Boolean = false,
|
||||||
|
val confirmExitMemby: Boolean = false,
|
||||||
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
|
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
|
||||||
/**
|
/**
|
||||||
* The viewer's own colour scheme, and the ones the gateway says they may pick between.
|
* 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 onShowCardMetadataChanged: (Boolean) -> Unit = {},
|
||||||
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
|
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
|
||||||
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
|
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
|
||||||
|
val onConfirmExitMembyChanged: (Boolean) -> Unit = {},
|
||||||
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
|
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
|
||||||
val onThemeChanged: (String) -> Unit = {},
|
val onThemeChanged: (String) -> Unit = {},
|
||||||
val onPageSelected: (SettingsPage) -> Unit = {},
|
val onPageSelected: (SettingsPage) -> Unit = {},
|
||||||
@@ -312,6 +314,7 @@ fun SettingsSheet(
|
|||||||
var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
|
var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
|
||||||
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
|
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
|
||||||
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
|
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
|
||||||
|
var confirmExitMemby by rememberSaveable { mutableStateOf(settings.confirmExitMemby) }
|
||||||
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
|
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
|
||||||
// The resolved theme and the schemes on offer both come from the gateway, through the
|
// 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
|
// sync that owns them. Collected here rather than in SettingsPanelContent so the content
|
||||||
@@ -375,6 +378,7 @@ fun SettingsSheet(
|
|||||||
settings.showHomeCardMetadata,
|
settings.showHomeCardMetadata,
|
||||||
settings.showRatingsStrip,
|
settings.showRatingsStrip,
|
||||||
settings.hideWatchedMovies,
|
settings.hideWatchedMovies,
|
||||||
|
settings.confirmExitMemby,
|
||||||
settings.autoPlayNextEpisode,
|
settings.autoPlayNextEpisode,
|
||||||
settings.showTenMinuteReminder,
|
settings.showTenMinuteReminder,
|
||||||
settings.seekIntervalSeconds,
|
settings.seekIntervalSeconds,
|
||||||
@@ -395,6 +399,7 @@ fun SettingsSheet(
|
|||||||
showCardMetadata = settings.showHomeCardMetadata
|
showCardMetadata = settings.showHomeCardMetadata
|
||||||
showRatingsStrip = settings.showRatingsStrip
|
showRatingsStrip = settings.showRatingsStrip
|
||||||
hideWatchedMovies = settings.hideWatchedMovies
|
hideWatchedMovies = settings.hideWatchedMovies
|
||||||
|
confirmExitMemby = settings.confirmExitMemby
|
||||||
welcomeQuoteStyle = settings.welcomeQuoteStyle
|
welcomeQuoteStyle = settings.welcomeQuoteStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +426,7 @@ fun SettingsSheet(
|
|||||||
showCardMetadata = showCardMetadata,
|
showCardMetadata = showCardMetadata,
|
||||||
showRatingsStrip = showRatingsStrip,
|
showRatingsStrip = showRatingsStrip,
|
||||||
hideWatchedMovies = hideWatchedMovies,
|
hideWatchedMovies = hideWatchedMovies,
|
||||||
|
confirmExitMemby = confirmExitMemby,
|
||||||
welcomeQuoteStyle = welcomeQuoteStyle,
|
welcomeQuoteStyle = welcomeQuoteStyle,
|
||||||
themeId = settings.themeId,
|
themeId = settings.themeId,
|
||||||
themeOptions = availableThemes.map { theme ->
|
themeOptions = availableThemes.map { theme ->
|
||||||
@@ -510,6 +516,10 @@ fun SettingsSheet(
|
|||||||
hideWatchedMovies = it
|
hideWatchedMovies = it
|
||||||
scope.launch { store.setHideWatchedMovies(it) }
|
scope.launch { store.setHideWatchedMovies(it) }
|
||||||
},
|
},
|
||||||
|
onConfirmExitMembyChanged = {
|
||||||
|
confirmExitMemby = it
|
||||||
|
scope.launch { store.setConfirmExitMemby(it) }
|
||||||
|
},
|
||||||
onThemeChanged = { chosen ->
|
onThemeChanged = { chosen ->
|
||||||
// No local echo: what is on screen is the palette the gateway resolves, and it
|
// 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
|
// arrives through ThemeSync a moment later. Painting optimistically here would
|
||||||
@@ -850,6 +860,13 @@ internal fun SettingsPanelContent(
|
|||||||
checked = state.showRatingsStrip,
|
checked = state.showRatingsStrip,
|
||||||
onCheckedChange = actions.onShowRatingsStripChanged,
|
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) {
|
if (state.hiddenHomeRowCount > 0) {
|
||||||
SettingDivider()
|
SettingDivider()
|
||||||
SettingsActionRow(
|
SettingsActionRow(
|
||||||
|
|||||||
@@ -185,9 +185,17 @@ class UserPreferencesTest {
|
|||||||
token = "session-token",
|
token = "session-token",
|
||||||
ringColorHex = "FF0000",
|
ringColorHex = "FF0000",
|
||||||
rotationIntervalSeconds = 45,
|
rotationIntervalSeconds = 45,
|
||||||
|
confirmExitMemby = true,
|
||||||
).toUserPreferences().encode().toString()
|
).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) }
|
.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