Release 0.2.49
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
## 0.2.49 — 2026-08-11
|
||||
- Fixed: D-pad focus now stays visible and predictable across My Shows, Settings, profile management, the TV calendar, detail pages and changing home rows.
|
||||
|
||||
## 0.2.48 — 2026-08-11
|
||||
- Improved: The TV calendar is now a modern weekly guide with artwork, title logos and clear season-premiere and finale labels.
|
||||
- Added: Home-screen metadata now shows the sound profile, including mono, stereo, 5.1 and 7.1.
|
||||
|
||||
@@ -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.48"
|
||||
val defaultVersionName = "0.2.49"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -88,6 +88,7 @@ fun EpisodeDetailsOverlay(
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
@@ -125,6 +126,7 @@ fun EpisodeDetailsOverlay(
|
||||
onOpenItem = onOpenItem,
|
||||
ratings = ratings,
|
||||
showRatingsStrip = settings.showRatingsStrip,
|
||||
restorePosition = restorePosition,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -142,14 +144,18 @@ internal fun EpisodeDetailContent(
|
||||
ratings: List<MediaRating> = emptyList(),
|
||||
showRatingsStrip: Boolean = true,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
) {
|
||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||
val currentSeason = item.parentIndexNumber
|
||||
val markers = remember(episodes, currentSeason) {
|
||||
seasonMarkers(episodes.orEmpty(), currentSeason)
|
||||
}
|
||||
// The scroller opens on the season this episode is in and stays wherever the viewer
|
||||
// moves it. Selecting a season browses; it never changes which episode the page is about.
|
||||
var selectedSeason by remember(item.id) { mutableStateOf(currentSeason) }
|
||||
var selectedSeason by remember(item.id, restorePosition) {
|
||||
mutableStateOf(if (restorePosition) remembered.season ?: currentSeason else currentSeason)
|
||||
}
|
||||
LaunchedEffect(markers) {
|
||||
if (markers.none { it.season == selectedSeason }) {
|
||||
selectedSeason = currentSeason ?: markers.firstOrNull()?.season
|
||||
@@ -167,7 +173,9 @@ internal fun EpisodeDetailContent(
|
||||
val firstEpisode = remember(item.id) { FocusRequester() }
|
||||
val emptyPane = remember(item.id) { FocusRequester() }
|
||||
val seasonRequesters = remember(markers) { markers.associate { it.season to FocusRequester() } }
|
||||
val episodeListState = rememberLazyListState()
|
||||
val episodeListState = rememberLazyListState(
|
||||
if (restorePosition) remembered.episodeIndex else 0,
|
||||
)
|
||||
val seasonListState = rememberLazyListState()
|
||||
|
||||
// Every focus target has to be attached on the frame the press lands. While the episode
|
||||
@@ -195,7 +203,7 @@ internal fun EpisodeDetailContent(
|
||||
|
||||
RestoreDetailFocus(
|
||||
itemId = item.id,
|
||||
zone = DetailZone.PLAY,
|
||||
zone = if (restorePosition) remembered.zone else DetailZone.PLAY,
|
||||
play = play,
|
||||
tabStrip = stripEntry,
|
||||
related = firstEpisode,
|
||||
|
||||
@@ -236,6 +236,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
@Composable
|
||||
fun TvNavigationRail(
|
||||
selected: BrowseDestination,
|
||||
focusDestination: BrowseDestination = selected,
|
||||
expanded: Boolean,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
onRailFocusChanged: (Boolean) -> Unit,
|
||||
@@ -361,7 +362,11 @@ fun TvNavigationRail(
|
||||
destination = destination,
|
||||
selected = destination == selected,
|
||||
expanded = expanded,
|
||||
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
|
||||
modifier = if (destination == focusDestination) {
|
||||
Modifier.focusRequester(navigationFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
onFocused = {},
|
||||
onClick = { onDestinationSelected(destination) },
|
||||
// My Alerts is one level in, behind the user picker. Without a mark out
|
||||
@@ -407,10 +412,12 @@ fun UserSwitcherOverlay(
|
||||
}
|
||||
val profileListState = remember(profileIds) { LazyListState() }
|
||||
val focusScope = rememberCoroutineScope()
|
||||
var profileFocusJob by remember(profileIds) { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
var focusedIndex by remember(profileIds) {
|
||||
mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
|
||||
}
|
||||
LaunchedEffect(profileIds, activeProfileId) {
|
||||
profileFocusJob?.cancel()
|
||||
focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId)
|
||||
if (profiles.isNotEmpty()) {
|
||||
profileListState.scrollToItem(focusedIndex)
|
||||
@@ -452,8 +459,9 @@ fun UserSwitcherOverlay(
|
||||
actionCount = UserSwitcherActionCount,
|
||||
)
|
||||
focusedIndex = next
|
||||
profileFocusJob?.cancel()
|
||||
if (next < profiles.size) {
|
||||
focusScope.launch {
|
||||
profileFocusJob = focusScope.launch {
|
||||
profileListState.scrollToItem(next)
|
||||
delay(16)
|
||||
runCatching { focusRequesters[next].requestFocus() }
|
||||
|
||||
@@ -1010,7 +1010,12 @@ private fun SetupScreen(
|
||||
kotlinx.coroutines.delay(100L)
|
||||
runCatching { usernameFocus.requestFocus() }
|
||||
}
|
||||
if (onCancel != null) BackHandler(onBack = onCancel)
|
||||
if (onCancel != null) {
|
||||
// The visible Back button is disabled while authentication is running. Hardware
|
||||
// Back must obey the same contract and, importantly, must not hide a form whose
|
||||
// coroutine can still complete and switch the active viewer afterwards.
|
||||
BackHandler { if (!connecting) onCancel() }
|
||||
}
|
||||
|
||||
val submit: () -> Unit = {
|
||||
if (username.isBlank()) {
|
||||
@@ -1132,6 +1137,7 @@ private fun ProfileChooser(
|
||||
val addFocus = remember { FocusRequester() }
|
||||
val backFocus = remember { FocusRequester() }
|
||||
var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
|
||||
var removalReturnIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val orderedProfiles = remember(profiles, currentProfileId) {
|
||||
profiles.sortedByDescending { it.id == currentProfileId }
|
||||
}
|
||||
@@ -1140,7 +1146,12 @@ private fun ProfileChooser(
|
||||
val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
|
||||
LaunchedEffect(profileIds) {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (orderedProfiles.isEmpty()) addFocus.requestFocus() else tileFocus.first().requestFocus()
|
||||
val targetIndex = removalReturnIndex?.let {
|
||||
profileFocusIndexAfterRemoval(it, orderedProfiles.size)
|
||||
} ?: if (orderedProfiles.isEmpty()) null else 0
|
||||
removalReturnIndex = null
|
||||
if (targetIndex == null) addFocus.requestFocus()
|
||||
else tileFocus.getOrNull(targetIndex)?.requestFocus() ?: addFocus.requestFocus()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -1254,6 +1265,7 @@ private fun ProfileChooser(
|
||||
profile = profile,
|
||||
onCancel = { pendingRemoval = null },
|
||||
onConfirm = {
|
||||
removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 }
|
||||
pendingRemoval = null
|
||||
onRemove(profile)
|
||||
},
|
||||
@@ -1262,6 +1274,9 @@ private fun ProfileChooser(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun profileFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
|
||||
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun RecommendationOnboardingScreen(
|
||||
@@ -1868,26 +1883,13 @@ private fun HomeScreen(
|
||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
|
||||
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
// The rail entry disappears the moment the operator turns the feature off; a television
|
||||
// that happened to be standing on the page has to be moved off it too, or it is left on
|
||||
// a destination nothing can navigate back to.
|
||||
LaunchedEffect(tvCalendarEnabled) {
|
||||
if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
}
|
||||
}
|
||||
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) }
|
||||
// Detail pages can now open one another through the related rail. Back walks the trail
|
||||
// home one page at a time; without this it would drop three levels to the launcher.
|
||||
var detailsTrail by remember { mutableStateOf<List<BaseItem>>(emptyList()) }
|
||||
var restoreDetailPosition by remember { mutableStateOf(false) }
|
||||
// Belongs to the *route*, not to the show: set only when a page is opened from the
|
||||
// "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the
|
||||
// same series reached from Favourites or a search never claims a schedule.
|
||||
@@ -1897,6 +1899,7 @@ private fun HomeScreen(
|
||||
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
|
||||
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
|
||||
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
|
||||
var myShowReturnItemId by remember { mutableStateOf<String?>(null) }
|
||||
var removingMyShow by remember { mutableStateOf(false) }
|
||||
var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) }
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
@@ -1931,14 +1934,37 @@ private fun HomeScreen(
|
||||
val navigationFocusRequesters = remember {
|
||||
BrowseDestination.entries.associateWith { FocusRequester() }
|
||||
}
|
||||
val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination)
|
||||
var railFocusDestination by rememberSaveable { mutableStateOf(selectedDestination) }
|
||||
val navigationFocusRequester = navigationFocusRequesters.getValue(railFocusDestination)
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
val cardReturnFocusRequester = remember { FocusRequester() }
|
||||
val myShowReturnFocusRequester = remember { FocusRequester() }
|
||||
// Down out of the hero, stated rather than left to Compose's spatial search. The
|
||||
// featured card is wide, so its centre sits nearer the second card of the shelf below
|
||||
// than the first, and the search obligingly skipped past the thing somebody had just
|
||||
// been reading about in Continue Watching.
|
||||
val heroRowEntryFocusRequester = remember { FocusRequester() }
|
||||
// Feature flags can remove the node that currently owns focus. Move the route first,
|
||||
// wait for its replacement to compose, then establish an explicit remote target.
|
||||
LaunchedEffect(tvCalendarEnabled) {
|
||||
if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
railFocusDestination = BrowseDestination.HOME
|
||||
kotlinx.coroutines.delay(16L)
|
||||
requestFirstAvailableFocus(
|
||||
contentFocusRequester,
|
||||
navigationFocusRequesters.getValue(BrowseDestination.HOME),
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(genreBrowserEnabled) {
|
||||
if (!genreBrowserEnabled && genreBrowseItemType != null) {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
kotlinx.coroutines.delay(16L)
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
}
|
||||
var initialFocusRequested by remember { mutableStateOf(false) }
|
||||
// Incremented when a rail selection needs to restore a card in the lazy row list.
|
||||
// The list owns the scroll state, so it also owns the actual restoration below.
|
||||
@@ -1961,7 +1987,13 @@ private fun HomeScreen(
|
||||
runCatching { repo.getNotifications() }.onSuccess { notificationState = it }
|
||||
kotlinx.coroutines.delay(32L)
|
||||
if (returnRowId != null && returnItemId != null) {
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
} else {
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2216,6 +2248,7 @@ private fun HomeScreen(
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
TvNavigationRail(
|
||||
selected = selectedDestination,
|
||||
focusDestination = railFocusDestination,
|
||||
expanded = navigationExpanded,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onRailFocusChanged = {
|
||||
@@ -2230,12 +2263,14 @@ private fun HomeScreen(
|
||||
genreBrowseItemType = null
|
||||
when (destination) {
|
||||
BrowseDestination.SETTINGS -> {
|
||||
railFocusDestination = BrowseDestination.SETTINGS
|
||||
restoreRailAfterSettings = true
|
||||
navigationExpanded = false
|
||||
userSwitcherVisible = false
|
||||
showSettings = true
|
||||
}
|
||||
BrowseDestination.PROFILES -> {
|
||||
railFocusDestination = BrowseDestination.PROFILES
|
||||
userSwitcherVisible = true
|
||||
navigationExpanded = false
|
||||
// The rail stays live beside Settings, so a destination
|
||||
@@ -2250,6 +2285,7 @@ private fun HomeScreen(
|
||||
showSettings = false
|
||||
restoreRailAfterSettings = false
|
||||
selectedDestination = destination
|
||||
railFocusDestination = destination
|
||||
val savedFocus = destinationFocus[destination]
|
||||
savedFocus?.let { (rowId, itemId) ->
|
||||
returnRowId = rowId
|
||||
@@ -2270,9 +2306,13 @@ private fun HomeScreen(
|
||||
// before transferring focus out of the rail.
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (savedFocus != null) {
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
} else {
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2635,12 +2675,15 @@ private fun HomeScreen(
|
||||
contentFocusRequester = contentFocusRequester.takeUnless {
|
||||
genreBrowserEnabled
|
||||
},
|
||||
returnFocusItemId = myShowReturnItemId,
|
||||
returnFocusRequester = myShowReturnFocusRequester,
|
||||
onShowSelected = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "shows",
|
||||
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
||||
itemId = it.itemId,
|
||||
)
|
||||
myShowReturnItemId = it.itemId
|
||||
selectedMyShow = it
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
@@ -2938,7 +2981,11 @@ private fun HomeScreen(
|
||||
runCatching { navigationFocusRequester.requestFocus() }
|
||||
restoreRailAfterSettings = false
|
||||
} else if (returnItemId != null) {
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
} else {
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
@@ -3031,16 +3078,23 @@ private fun HomeScreen(
|
||||
val previous = detailsTrail.lastOrNull()
|
||||
if (previous != null) {
|
||||
detailsTrail = detailsTrail.dropLast(1)
|
||||
restoreDetailPosition = true
|
||||
detailsItem = previous
|
||||
} else {
|
||||
restoreDetailPosition = false
|
||||
detailsItem = null
|
||||
detailsAiringNotice = null
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
FocusedDetailsOverlay(
|
||||
homeViewModel = homeViewModel,
|
||||
selected = selected,
|
||||
restorePosition = restoreDetailPosition,
|
||||
airingNotice = detailsAiringNotice,
|
||||
onOpenItem = { related ->
|
||||
homeViewModel.trackJourney(
|
||||
@@ -3049,6 +3103,7 @@ private fun HomeScreen(
|
||||
itemId = related.id, itemType = related.type,
|
||||
)
|
||||
detailsTrail = detailsTrail + selected
|
||||
restoreDetailPosition = false
|
||||
// Ask for the full record the same way a focused card does. Some
|
||||
// routes here hand over a stub rather than a row item — the episode
|
||||
// page's "Show" button knows only the series' id and name — and
|
||||
@@ -3064,6 +3119,7 @@ private fun HomeScreen(
|
||||
onPlay = {
|
||||
detailsItem = null
|
||||
detailsTrail = emptyList()
|
||||
restoreDetailPosition = false
|
||||
detailsAiringNotice = null
|
||||
playItem(it)
|
||||
},
|
||||
@@ -3123,13 +3179,31 @@ private fun HomeScreen(
|
||||
onClose = {
|
||||
detailsItem = null
|
||||
detailsTrail = emptyList()
|
||||
restoreDetailPosition = false
|
||||
detailsAiringNotice = null
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
selectedMyShow?.let { show ->
|
||||
BackHandler { selectedMyShow = null }
|
||||
val closeMyShow: (Boolean) -> Unit = { removed ->
|
||||
selectedMyShow = null
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
val exactStillExists = !removed && myShows.any { it.itemId == myShowReturnItemId }
|
||||
val restored = exactStillExists && runCatching {
|
||||
myShowReturnFocusRequester.requestFocus()
|
||||
}.isSuccess
|
||||
if (!restored && runCatching { contentFocusRequester.requestFocus() }.isFailure) {
|
||||
runCatching { navigationFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
BackHandler { closeMyShow(false) }
|
||||
MyShowDetailsOverlay(
|
||||
show = show,
|
||||
repository = repo,
|
||||
@@ -3139,12 +3213,12 @@ private fun HomeScreen(
|
||||
scope.launch {
|
||||
runCatching { repo.removeMyShow(show.itemId) }.onSuccess {
|
||||
myShows = myShows.filterNot { it.itemId == show.itemId }
|
||||
selectedMyShow = null
|
||||
closeMyShow(true)
|
||||
}
|
||||
removingMyShow = false
|
||||
}
|
||||
},
|
||||
onClose = { selectedMyShow = null },
|
||||
onClose = { closeMyShow(false) },
|
||||
)
|
||||
}
|
||||
if (showNotifications) {
|
||||
@@ -3225,17 +3299,25 @@ private fun HomeScreen(
|
||||
)
|
||||
}
|
||||
quickMenuItem?.let { selected ->
|
||||
val closeQuickActions: () -> Unit = {
|
||||
val closeQuickActions: (Boolean) -> Unit = { originWillDisappear ->
|
||||
quickMenuItem = null
|
||||
quickMenuRowId = null
|
||||
scope.launch {
|
||||
// The overlay owns focus until it leaves composition. Restore the
|
||||
// exact originating card after its focus node is available again.
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
if (originWillDisappear) {
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
} else {
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = closeQuickActions)
|
||||
BackHandler { closeQuickActions(false) }
|
||||
FocusedQuickActionsOverlay(
|
||||
homeViewModel = homeViewModel,
|
||||
selected = selected,
|
||||
@@ -3262,9 +3344,11 @@ private fun HomeScreen(
|
||||
} else {
|
||||
contentFocusRequester
|
||||
}
|
||||
if (runCatching { removalFocusRequester.requestFocus() }.isFailure) {
|
||||
runCatching { navigationFocusRequester.requestFocus() }
|
||||
}
|
||||
requestFirstAvailableFocus(
|
||||
removalFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3283,7 +3367,7 @@ private fun HomeScreen(
|
||||
settings.homeHiddenRows.decodeRowIds().toSet(),
|
||||
)
|
||||
}
|
||||
closeQuickActions()
|
||||
closeQuickActions(false)
|
||||
}
|
||||
},
|
||||
onHideRow = quickMenuRowId?.let { rowId ->
|
||||
@@ -3296,8 +3380,8 @@ private fun HomeScreen(
|
||||
settings.homePinnedRows.decodeRowIds().toSet(),
|
||||
hidden,
|
||||
)
|
||||
closeQuickActions(true)
|
||||
}
|
||||
closeQuickActions()
|
||||
}
|
||||
},
|
||||
onMoveRow = quickMenuRowId?.let { rowId ->
|
||||
@@ -3316,10 +3400,10 @@ private fun HomeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
closeQuickActions()
|
||||
closeQuickActions(false)
|
||||
}
|
||||
},
|
||||
onClose = closeQuickActions,
|
||||
onClose = { closeQuickActions(false) },
|
||||
)
|
||||
}
|
||||
resolvingItem?.let { item ->
|
||||
@@ -3645,6 +3729,7 @@ private fun FocusedHomeMetadata(
|
||||
private fun FocusedDetailsOverlay(
|
||||
homeViewModel: HomeViewModel,
|
||||
selected: BaseItem,
|
||||
restorePosition: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
@@ -3665,6 +3750,7 @@ private fun FocusedDetailsOverlay(
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
onClose = onClose,
|
||||
onOpenItem = onOpenItem,
|
||||
restorePosition = restorePosition,
|
||||
airingNotice = airingNotice,
|
||||
)
|
||||
} else if (item.isEpisode) {
|
||||
@@ -3678,6 +3764,7 @@ private fun FocusedDetailsOverlay(
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onClose = onClose,
|
||||
onOpenItem = onOpenItem,
|
||||
restorePosition = restorePosition,
|
||||
)
|
||||
} else {
|
||||
MediaDetailsOverlay(
|
||||
@@ -3687,6 +3774,7 @@ private fun FocusedDetailsOverlay(
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onClose = onClose,
|
||||
onOpenItem = onOpenItem,
|
||||
restorePosition = restorePosition,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4294,6 +4382,14 @@ internal fun TvTextField(
|
||||
}
|
||||
}
|
||||
|
||||
/** Requests the first focus target that is both attached and willing to accept focus. */
|
||||
private fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean {
|
||||
requesters.forEach { requester ->
|
||||
if (runCatching { requester.requestFocus() }.isSuccess) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openScreensaverSettings(context: android.content.Context) {
|
||||
val candidates = listOf(
|
||||
AndroidSettings.ACTION_DREAM_SETTINGS,
|
||||
|
||||
@@ -50,6 +50,7 @@ fun MediaDetailsOverlay(
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val settings by ServiceLocator.repository.settingsFlow
|
||||
@@ -77,6 +78,7 @@ fun MediaDetailsOverlay(
|
||||
ratings = ratings,
|
||||
showRatingsStrip = settings.showRatingsStrip,
|
||||
hideWatchedMovies = settings.hideWatchedMovies,
|
||||
restorePosition = restorePosition,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -98,6 +100,7 @@ internal fun MediaDetailContent(
|
||||
showRatingsStrip: Boolean = true,
|
||||
hideWatchedMovies: Boolean = false,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
) {
|
||||
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
|
||||
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
|
||||
@@ -116,7 +119,9 @@ internal fun MediaDetailContent(
|
||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||
// Every newly opened title starts as a complete hero frame. Remembering a content
|
||||
// tab also restored its focus and caused the page to reopen below the artwork.
|
||||
var tabKey by remember(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
|
||||
var tabKey by remember(item.id, restorePosition) {
|
||||
mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key)
|
||||
}
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
@@ -142,7 +147,7 @@ internal fun MediaDetailContent(
|
||||
)
|
||||
RestoreDetailFocus(
|
||||
itemId = item.id,
|
||||
zone = DetailZone.PLAY,
|
||||
zone = if (restorePosition) remembered.zone else DetailZone.PLAY,
|
||||
play = play,
|
||||
tabStrip = tabStrip,
|
||||
related = firstRelated,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
@@ -15,10 +17,13 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -56,6 +61,8 @@ internal fun MyShowsStrip(
|
||||
density: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester?,
|
||||
returnFocusItemId: String? = null,
|
||||
returnFocusRequester: FocusRequester? = null,
|
||||
onShowSelected: (MyShow) -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
) {
|
||||
@@ -121,6 +128,13 @@ internal fun MyShowsStrip(
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (show.itemId == returnFocusItemId && returnFocusRequester != null) {
|
||||
Modifier.focusRequester(returnFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
||||
)
|
||||
@@ -236,6 +250,20 @@ internal fun MyShowDetailsOverlay(
|
||||
onRemove: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val removeFocus = remember(show.itemId) { FocusRequester() }
|
||||
val closeFocus = remember(show.itemId) { FocusRequester() }
|
||||
LaunchedEffect(show.itemId) {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (runCatching { removeFocus.requestFocus() }.isFailure) {
|
||||
runCatching { closeFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(removing) {
|
||||
if (removing) {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { closeFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().zIndex(8f).background(MembySurface.copy(alpha = 0.96f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -264,11 +292,35 @@ internal fun MyShowDetailsOverlay(
|
||||
StatusLine("Sonarr", show.sonarrStatus)
|
||||
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
||||
StatusLine("Series status", show.lifecycle)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRemove, enabled = !removing) {
|
||||
Row(
|
||||
modifier = Modifier.focusGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = onRemove,
|
||||
enabled = !removing,
|
||||
modifier = Modifier
|
||||
.focusRequester(removeFocus)
|
||||
.focusProperties {
|
||||
left = FocusRequester.Cancel
|
||||
right = closeFocus
|
||||
up = FocusRequester.Cancel
|
||||
down = FocusRequester.Cancel
|
||||
},
|
||||
) {
|
||||
Text(if (removing) "Removing…" else "Remove from My Shows")
|
||||
}
|
||||
Button(onClick = onClose) { Text("Close") }
|
||||
Button(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.focusRequester(closeFocus)
|
||||
.focusProperties {
|
||||
left = if (removing) FocusRequester.Cancel else removeFocus
|
||||
right = FocusRequester.Cancel
|
||||
up = FocusRequester.Cancel
|
||||
down = FocusRequester.Cancel
|
||||
},
|
||||
) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ fun SeriesDetailsOverlay(
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
airingNotice: AiringNotice? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -153,6 +154,7 @@ fun SeriesDetailsOverlay(
|
||||
showRatingsStrip = settings.showRatingsStrip,
|
||||
hideWatchedMovies = settings.hideWatchedMovies,
|
||||
onOpenItem = onOpenItem,
|
||||
restorePosition = restorePosition,
|
||||
airingNotice = airingNotice,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -175,6 +177,7 @@ internal fun SeriesDetailContent(
|
||||
showRatingsStrip: Boolean = true,
|
||||
hideWatchedMovies: Boolean = false,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
airingNotice: AiringNotice? = null,
|
||||
) {
|
||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||
@@ -217,7 +220,9 @@ internal fun SeriesDetailContent(
|
||||
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
||||
// A series opens on its cinematic hero and Overview just like a movie. Season and
|
||||
// rail positions are still remembered once the viewer enters Episodes.
|
||||
var tabKey by rememberSaveable(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
|
||||
var tabKey by rememberSaveable(item.id, restorePosition) {
|
||||
mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key)
|
||||
}
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
@@ -265,7 +270,7 @@ internal fun SeriesDetailContent(
|
||||
)
|
||||
RestoreDetailFocus(
|
||||
itemId = item.id,
|
||||
zone = DetailZone.PLAY,
|
||||
zone = if (restorePosition) remembered.zone else DetailZone.PLAY,
|
||||
play = play,
|
||||
tabStrip = tabStrip,
|
||||
related = firstRelated,
|
||||
|
||||
@@ -101,7 +101,7 @@ fun MyAlertsPage(
|
||||
val listState = rememberLazyListState()
|
||||
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val hasAlerts = notifications.isNotEmpty()
|
||||
LaunchedEffect(hasAlerts) {
|
||||
LaunchedEffect(Unit) {
|
||||
// One frame for the list to place its first row; an empty page has nothing below
|
||||
// the actions to land on, so the chips take the remote instead.
|
||||
delay(16)
|
||||
@@ -110,13 +110,18 @@ fun MyAlertsPage(
|
||||
}
|
||||
}
|
||||
LaunchedEffect(notificationIds) {
|
||||
if (notifications.isEmpty()) {
|
||||
pendingFocusIndex = null
|
||||
delay(16)
|
||||
runCatching { actionsFocusRequester.requestFocus() }
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
|
||||
// Spent on this list change however it turns out. Left set, a request that could
|
||||
// not be honoured — an empty list, a dismissal the server refused and put back —
|
||||
// would be honoured against the *next* change instead, which is commonly an alert
|
||||
// arriving on its own: focus would jump for a press made minutes ago.
|
||||
pendingFocusIndex = null
|
||||
if (notifications.isEmpty()) return@LaunchedEffect
|
||||
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
|
||||
?: return@LaunchedEffect
|
||||
runCatching { listState.scrollToItem(targetIndex) }
|
||||
|
||||
@@ -98,6 +98,10 @@ internal fun CalendarContent(
|
||||
val selectedDay = week?.cells?.firstOrNull { it.date == state.selectedDate }
|
||||
?: week?.cells?.firstOrNull { !it.isPad }
|
||||
val selectedItems = remember(selectedDay) { selectedDay?.items.orEmpty().distinctItems() }
|
||||
val monthFocusAnchor = calendarMonthFocusAnchor(
|
||||
previousMonth = state.calendar.previous,
|
||||
nextMonth = state.calendar.next,
|
||||
)
|
||||
|
||||
val monthFocusRequester = remember { FocusRequester() }
|
||||
val weekFocusRequester = remember { FocusRequester() }
|
||||
@@ -162,6 +166,7 @@ internal fun CalendarContent(
|
||||
weekCount = weeks.size,
|
||||
focusRequester = weekFocusRequester,
|
||||
monthFocusRequester = monthFocusRequester,
|
||||
hasMonthControl = monthFocusAnchor != CalendarMonthFocusAnchor.NONE,
|
||||
onPrevious = {
|
||||
weeks.getOrNull(weekIndex - 1)?.let {
|
||||
onSelectDate(calendarAgendaWeekDate(it))
|
||||
@@ -221,6 +226,7 @@ private fun AgendaHeader(
|
||||
weekFocusRequester: FocusRequester,
|
||||
onShowMonth: (String) -> Unit,
|
||||
) {
|
||||
val monthFocusAnchor = calendarMonthFocusAnchor(previousMonth, nextMonth)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
||||
@@ -244,7 +250,15 @@ private fun AgendaHeader(
|
||||
description = "Previous month",
|
||||
enabled = previousMonth.isNotEmpty(),
|
||||
onClick = { onShowMonth(previousMonth) },
|
||||
modifier = Modifier.focusProperties { down = weekFocusRequester },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (monthFocusAnchor == CalendarMonthFocusAnchor.PREVIOUS) {
|
||||
Modifier.focusRequester(monthFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties { down = weekFocusRequester },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
AgendaIconButton(
|
||||
@@ -253,12 +267,29 @@ private fun AgendaHeader(
|
||||
enabled = nextMonth.isNotEmpty(),
|
||||
onClick = { onShowMonth(nextMonth) },
|
||||
modifier = Modifier
|
||||
.focusRequester(monthFocusRequester)
|
||||
.then(
|
||||
if (monthFocusAnchor == CalendarMonthFocusAnchor.NEXT) {
|
||||
Modifier.focusRequester(monthFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties { down = weekFocusRequester },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class CalendarMonthFocusAnchor { PREVIOUS, NEXT, NONE }
|
||||
|
||||
internal fun calendarMonthFocusAnchor(
|
||||
previousMonth: String,
|
||||
nextMonth: String,
|
||||
): CalendarMonthFocusAnchor = when {
|
||||
nextMonth.isNotEmpty() -> CalendarMonthFocusAnchor.NEXT
|
||||
previousMonth.isNotEmpty() -> CalendarMonthFocusAnchor.PREVIOUS
|
||||
else -> CalendarMonthFocusAnchor.NONE
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSwitcher(
|
||||
week: CalendarAgendaWeek,
|
||||
@@ -266,6 +297,7 @@ private fun WeekSwitcher(
|
||||
weekCount: Int,
|
||||
focusRequester: FocusRequester,
|
||||
monthFocusRequester: FocusRequester,
|
||||
hasMonthControl: Boolean,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
@@ -283,7 +315,9 @@ private fun WeekSwitcher(
|
||||
contentDescription = "Week ${week.label}",
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { up = monthFocusRequester }
|
||||
.focusProperties {
|
||||
up = if (hasMonthControl) monthFocusRequester else FocusRequester.Default
|
||||
}
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.62f), RoundedCornerShape(MembyChipCorner)),
|
||||
) { focused ->
|
||||
Row(
|
||||
|
||||
@@ -1223,11 +1223,19 @@ private fun DeviceRenameDialog(
|
||||
val cancelFocus = remember { FocusRequester() }
|
||||
val saveFocus = remember { FocusRequester() }
|
||||
val valid = name.trim().isNotEmpty() && name.length <= 80
|
||||
BackHandler(enabled = !busy, onBack = onCancel)
|
||||
// Keep this handler above Settings even while the request is in flight. Disabling it
|
||||
// lets Back close the whole Settings destination behind the still-running rename.
|
||||
BackHandler { if (!busy) onCancel() }
|
||||
LaunchedEffect(device.deviceId) {
|
||||
delay(80L)
|
||||
runCatching { fieldFocus.requestFocus() }
|
||||
}
|
||||
LaunchedEffect(busy) {
|
||||
if (busy) {
|
||||
delay(16L)
|
||||
runCatching { cancelFocus.requestFocus() }
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -1273,10 +1281,14 @@ private fun DeviceRenameDialog(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||
) {
|
||||
Button(
|
||||
onClick = onCancel,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.focusRequester(cancelFocus).focusProperties { right = saveFocus },
|
||||
) { Text("Cancel") }
|
||||
onClick = { if (!busy) onCancel() },
|
||||
// This remains the modal's stable focus owner while Save and the field
|
||||
// are disabled. Select is deliberately a no-op until the request ends.
|
||||
enabled = true,
|
||||
modifier = Modifier.focusRequester(cancelFocus).focusProperties {
|
||||
right = if (busy) FocusRequester.Cancel else saveFocus
|
||||
},
|
||||
) { Text(if (busy) "Please wait…" else "Cancel") }
|
||||
Button(
|
||||
onClick = { onSave(name) },
|
||||
enabled = valid && !busy,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class UserSwitcherNavigationTest {
|
||||
@Test
|
||||
fun `removed profile hands focus to its stable neighbour`() {
|
||||
assertEquals(1, profileFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3))
|
||||
assertEquals(2, profileFocusIndexAfterRemoval(removedIndex = 3, remainingCount = 3))
|
||||
assertNull(profileFocusIndexAfterRemoval(removedIndex = 0, remainingCount = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active profile receives initial focus`() {
|
||||
assertEquals(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ponzischeme89.memby.ui.calendar
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class CalendarFocusTest {
|
||||
@Test
|
||||
fun `next month is the normal focus anchor`() {
|
||||
assertEquals(
|
||||
CalendarMonthFocusAnchor.NEXT,
|
||||
calendarMonthFocusAnchor(previousMonth = "2026-07", nextMonth = "2026-09"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest month anchors focus to previous month`() {
|
||||
assertEquals(
|
||||
CalendarMonthFocusAnchor.PREVIOUS,
|
||||
calendarMonthFocusAnchor(previousMonth = "2026-12", nextMonth = ""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar without travel controls uses ordinary focus search`() {
|
||||
assertEquals(
|
||||
CalendarMonthFocusAnchor.NONE,
|
||||
calendarMonthFocusAnchor(previousMonth = "", nextMonth = ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user