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
|
## 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.
|
- 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.
|
- 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
|
// 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.48"
|
val defaultVersionName = "0.2.49"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ fun EpisodeDetailsOverlay(
|
|||||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
|
restorePosition: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val repository = ServiceLocator.repository
|
val repository = ServiceLocator.repository
|
||||||
@@ -125,6 +126,7 @@ fun EpisodeDetailsOverlay(
|
|||||||
onOpenItem = onOpenItem,
|
onOpenItem = onOpenItem,
|
||||||
ratings = ratings,
|
ratings = ratings,
|
||||||
showRatingsStrip = settings.showRatingsStrip,
|
showRatingsStrip = settings.showRatingsStrip,
|
||||||
|
restorePosition = restorePosition,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,14 +144,18 @@ internal fun EpisodeDetailContent(
|
|||||||
ratings: List<MediaRating> = emptyList(),
|
ratings: List<MediaRating> = emptyList(),
|
||||||
showRatingsStrip: Boolean = true,
|
showRatingsStrip: Boolean = true,
|
||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
|
restorePosition: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||||
val currentSeason = item.parentIndexNumber
|
val currentSeason = item.parentIndexNumber
|
||||||
val markers = remember(episodes, currentSeason) {
|
val markers = remember(episodes, currentSeason) {
|
||||||
seasonMarkers(episodes.orEmpty(), currentSeason)
|
seasonMarkers(episodes.orEmpty(), currentSeason)
|
||||||
}
|
}
|
||||||
// The scroller opens on the season this episode is in and stays wherever the viewer
|
// 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.
|
// 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) {
|
LaunchedEffect(markers) {
|
||||||
if (markers.none { it.season == selectedSeason }) {
|
if (markers.none { it.season == selectedSeason }) {
|
||||||
selectedSeason = currentSeason ?: markers.firstOrNull()?.season
|
selectedSeason = currentSeason ?: markers.firstOrNull()?.season
|
||||||
@@ -167,7 +173,9 @@ internal fun EpisodeDetailContent(
|
|||||||
val firstEpisode = remember(item.id) { FocusRequester() }
|
val firstEpisode = remember(item.id) { FocusRequester() }
|
||||||
val emptyPane = remember(item.id) { FocusRequester() }
|
val emptyPane = remember(item.id) { FocusRequester() }
|
||||||
val seasonRequesters = remember(markers) { markers.associate { it.season to 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()
|
val seasonListState = rememberLazyListState()
|
||||||
|
|
||||||
// Every focus target has to be attached on the frame the press lands. While the episode
|
// Every focus target has to be attached on the frame the press lands. While the episode
|
||||||
@@ -195,7 +203,7 @@ internal fun EpisodeDetailContent(
|
|||||||
|
|
||||||
RestoreDetailFocus(
|
RestoreDetailFocus(
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
zone = DetailZone.PLAY,
|
zone = if (restorePosition) remembered.zone else DetailZone.PLAY,
|
||||||
play = play,
|
play = play,
|
||||||
tabStrip = stripEntry,
|
tabStrip = stripEntry,
|
||||||
related = firstEpisode,
|
related = firstEpisode,
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
|||||||
@Composable
|
@Composable
|
||||||
fun TvNavigationRail(
|
fun TvNavigationRail(
|
||||||
selected: BrowseDestination,
|
selected: BrowseDestination,
|
||||||
|
focusDestination: BrowseDestination = selected,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
onRailFocusChanged: (Boolean) -> Unit,
|
onRailFocusChanged: (Boolean) -> Unit,
|
||||||
@@ -361,7 +362,11 @@ fun TvNavigationRail(
|
|||||||
destination = destination,
|
destination = destination,
|
||||||
selected = destination == selected,
|
selected = destination == selected,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
|
modifier = if (destination == focusDestination) {
|
||||||
|
Modifier.focusRequester(navigationFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
onFocused = {},
|
onFocused = {},
|
||||||
onClick = { onDestinationSelected(destination) },
|
onClick = { onDestinationSelected(destination) },
|
||||||
// My Alerts is one level in, behind the user picker. Without a mark out
|
// 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 profileListState = remember(profileIds) { LazyListState() }
|
||||||
val focusScope = rememberCoroutineScope()
|
val focusScope = rememberCoroutineScope()
|
||||||
|
var profileFocusJob by remember(profileIds) { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||||
var focusedIndex by remember(profileIds) {
|
var focusedIndex by remember(profileIds) {
|
||||||
mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
|
mutableStateOf(userSwitcherInitialIndex(profileIds, activeProfileId))
|
||||||
}
|
}
|
||||||
LaunchedEffect(profileIds, activeProfileId) {
|
LaunchedEffect(profileIds, activeProfileId) {
|
||||||
|
profileFocusJob?.cancel()
|
||||||
focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId)
|
focusedIndex = userSwitcherInitialIndex(profileIds, activeProfileId)
|
||||||
if (profiles.isNotEmpty()) {
|
if (profiles.isNotEmpty()) {
|
||||||
profileListState.scrollToItem(focusedIndex)
|
profileListState.scrollToItem(focusedIndex)
|
||||||
@@ -452,8 +459,9 @@ fun UserSwitcherOverlay(
|
|||||||
actionCount = UserSwitcherActionCount,
|
actionCount = UserSwitcherActionCount,
|
||||||
)
|
)
|
||||||
focusedIndex = next
|
focusedIndex = next
|
||||||
|
profileFocusJob?.cancel()
|
||||||
if (next < profiles.size) {
|
if (next < profiles.size) {
|
||||||
focusScope.launch {
|
profileFocusJob = focusScope.launch {
|
||||||
profileListState.scrollToItem(next)
|
profileListState.scrollToItem(next)
|
||||||
delay(16)
|
delay(16)
|
||||||
runCatching { focusRequesters[next].requestFocus() }
|
runCatching { focusRequesters[next].requestFocus() }
|
||||||
|
|||||||
@@ -1010,7 +1010,12 @@ private fun SetupScreen(
|
|||||||
kotlinx.coroutines.delay(100L)
|
kotlinx.coroutines.delay(100L)
|
||||||
runCatching { usernameFocus.requestFocus() }
|
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 = {
|
val submit: () -> Unit = {
|
||||||
if (username.isBlank()) {
|
if (username.isBlank()) {
|
||||||
@@ -1132,6 +1137,7 @@ private fun ProfileChooser(
|
|||||||
val addFocus = remember { FocusRequester() }
|
val addFocus = remember { FocusRequester() }
|
||||||
val backFocus = remember { FocusRequester() }
|
val backFocus = remember { FocusRequester() }
|
||||||
var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
|
var pendingRemoval by remember { mutableStateOf<EmbyProfile?>(null) }
|
||||||
|
var removalReturnIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
val orderedProfiles = remember(profiles, currentProfileId) {
|
val orderedProfiles = remember(profiles, currentProfileId) {
|
||||||
profiles.sortedByDescending { it.id == currentProfileId }
|
profiles.sortedByDescending { it.id == currentProfileId }
|
||||||
}
|
}
|
||||||
@@ -1140,7 +1146,12 @@ private fun ProfileChooser(
|
|||||||
val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
|
val removeFocus = remember(profileIds) { List(orderedProfiles.size) { FocusRequester() } }
|
||||||
LaunchedEffect(profileIds) {
|
LaunchedEffect(profileIds) {
|
||||||
kotlinx.coroutines.delay(16L)
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1254,6 +1265,7 @@ private fun ProfileChooser(
|
|||||||
profile = profile,
|
profile = profile,
|
||||||
onCancel = { pendingRemoval = null },
|
onCancel = { pendingRemoval = null },
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
|
removalReturnIndex = profileIds.indexOf(profile.id).takeIf { it >= 0 }
|
||||||
pendingRemoval = null
|
pendingRemoval = null
|
||||||
onRemove(profile)
|
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
|
@Composable
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
private fun RecommendationOnboardingScreen(
|
private fun RecommendationOnboardingScreen(
|
||||||
@@ -1868,26 +1883,13 @@ private fun HomeScreen(
|
|||||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||||
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
|
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
|
||||||
var genreBrowseInitialCategoryId 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 navigationExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var restoreRailAfterSettings by remember { mutableStateOf(false) }
|
var restoreRailAfterSettings by remember { mutableStateOf(false) }
|
||||||
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
|
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
// Detail pages can now open one another through the related rail. Back walks the trail
|
// 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.
|
// 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 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
|
// 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
|
// "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.
|
// 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 focusedHomeRowId by remember { mutableStateOf<String?>(null) }
|
||||||
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
|
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
|
||||||
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
|
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
|
||||||
|
var myShowReturnItemId by remember { mutableStateOf<String?>(null) }
|
||||||
var removingMyShow by remember { mutableStateOf(false) }
|
var removingMyShow by remember { mutableStateOf(false) }
|
||||||
var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) }
|
var notificationState by remember(settings.userId) { mutableStateOf(NotificationsResponse()) }
|
||||||
var showNotifications by remember { mutableStateOf(false) }
|
var showNotifications by remember { mutableStateOf(false) }
|
||||||
@@ -1931,14 +1934,37 @@ private fun HomeScreen(
|
|||||||
val navigationFocusRequesters = remember {
|
val navigationFocusRequesters = remember {
|
||||||
BrowseDestination.entries.associateWith { FocusRequester() }
|
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 contentFocusRequester = remember { FocusRequester() }
|
||||||
val cardReturnFocusRequester = 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
|
// 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
|
// 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
|
// than the first, and the search obligingly skipped past the thing somebody had just
|
||||||
// been reading about in Continue Watching.
|
// been reading about in Continue Watching.
|
||||||
val heroRowEntryFocusRequester = remember { FocusRequester() }
|
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) }
|
var initialFocusRequested by remember { mutableStateOf(false) }
|
||||||
// Incremented when a rail selection needs to restore a card in the lazy row list.
|
// 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.
|
// 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 }
|
runCatching { repo.getNotifications() }.onSuccess { notificationState = it }
|
||||||
kotlinx.coroutines.delay(32L)
|
kotlinx.coroutines.delay(32L)
|
||||||
if (returnRowId != null && returnItemId != null) {
|
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()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
TvNavigationRail(
|
TvNavigationRail(
|
||||||
selected = selectedDestination,
|
selected = selectedDestination,
|
||||||
|
focusDestination = railFocusDestination,
|
||||||
expanded = navigationExpanded,
|
expanded = navigationExpanded,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
onRailFocusChanged = {
|
onRailFocusChanged = {
|
||||||
@@ -2230,12 +2263,14 @@ private fun HomeScreen(
|
|||||||
genreBrowseItemType = null
|
genreBrowseItemType = null
|
||||||
when (destination) {
|
when (destination) {
|
||||||
BrowseDestination.SETTINGS -> {
|
BrowseDestination.SETTINGS -> {
|
||||||
|
railFocusDestination = BrowseDestination.SETTINGS
|
||||||
restoreRailAfterSettings = true
|
restoreRailAfterSettings = true
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
userSwitcherVisible = false
|
userSwitcherVisible = false
|
||||||
showSettings = true
|
showSettings = true
|
||||||
}
|
}
|
||||||
BrowseDestination.PROFILES -> {
|
BrowseDestination.PROFILES -> {
|
||||||
|
railFocusDestination = BrowseDestination.PROFILES
|
||||||
userSwitcherVisible = true
|
userSwitcherVisible = true
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
// The rail stays live beside Settings, so a destination
|
// The rail stays live beside Settings, so a destination
|
||||||
@@ -2250,6 +2285,7 @@ private fun HomeScreen(
|
|||||||
showSettings = false
|
showSettings = false
|
||||||
restoreRailAfterSettings = false
|
restoreRailAfterSettings = false
|
||||||
selectedDestination = destination
|
selectedDestination = destination
|
||||||
|
railFocusDestination = destination
|
||||||
val savedFocus = destinationFocus[destination]
|
val savedFocus = destinationFocus[destination]
|
||||||
savedFocus?.let { (rowId, itemId) ->
|
savedFocus?.let { (rowId, itemId) ->
|
||||||
returnRowId = rowId
|
returnRowId = rowId
|
||||||
@@ -2270,9 +2306,13 @@ private fun HomeScreen(
|
|||||||
// before transferring focus out of the rail.
|
// before transferring focus out of the rail.
|
||||||
kotlinx.coroutines.delay(16L)
|
kotlinx.coroutines.delay(16L)
|
||||||
if (savedFocus != null) {
|
if (savedFocus != null) {
|
||||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
requestFirstAvailableFocus(
|
||||||
|
cardReturnFocusRequester,
|
||||||
|
contentFocusRequester,
|
||||||
|
navigationFocusRequester,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2635,12 +2675,15 @@ private fun HomeScreen(
|
|||||||
contentFocusRequester = contentFocusRequester.takeUnless {
|
contentFocusRequester = contentFocusRequester.takeUnless {
|
||||||
genreBrowserEnabled
|
genreBrowserEnabled
|
||||||
},
|
},
|
||||||
|
returnFocusItemId = myShowReturnItemId,
|
||||||
|
returnFocusRequester = myShowReturnFocusRequester,
|
||||||
onShowSelected = {
|
onShowSelected = {
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "shows",
|
category = "content", action = "open", screen = "shows",
|
||||||
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
||||||
itemId = it.itemId,
|
itemId = it.itemId,
|
||||||
)
|
)
|
||||||
|
myShowReturnItemId = it.itemId
|
||||||
selectedMyShow = it
|
selectedMyShow = it
|
||||||
},
|
},
|
||||||
onContentFocused = { navigationExpanded = false },
|
onContentFocused = { navigationExpanded = false },
|
||||||
@@ -2938,7 +2981,11 @@ private fun HomeScreen(
|
|||||||
runCatching { navigationFocusRequester.requestFocus() }
|
runCatching { navigationFocusRequester.requestFocus() }
|
||||||
restoreRailAfterSettings = false
|
restoreRailAfterSettings = false
|
||||||
} else if (returnItemId != null) {
|
} else if (returnItemId != null) {
|
||||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
requestFirstAvailableFocus(
|
||||||
|
cardReturnFocusRequester,
|
||||||
|
contentFocusRequester,
|
||||||
|
navigationFocusRequester,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
}
|
}
|
||||||
@@ -3031,16 +3078,23 @@ private fun HomeScreen(
|
|||||||
val previous = detailsTrail.lastOrNull()
|
val previous = detailsTrail.lastOrNull()
|
||||||
if (previous != null) {
|
if (previous != null) {
|
||||||
detailsTrail = detailsTrail.dropLast(1)
|
detailsTrail = detailsTrail.dropLast(1)
|
||||||
|
restoreDetailPosition = true
|
||||||
detailsItem = previous
|
detailsItem = previous
|
||||||
} else {
|
} else {
|
||||||
|
restoreDetailPosition = false
|
||||||
detailsItem = null
|
detailsItem = null
|
||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
requestFirstAvailableFocus(
|
||||||
|
cardReturnFocusRequester,
|
||||||
|
contentFocusRequester,
|
||||||
|
navigationFocusRequester,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FocusedDetailsOverlay(
|
FocusedDetailsOverlay(
|
||||||
homeViewModel = homeViewModel,
|
homeViewModel = homeViewModel,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
|
restorePosition = restoreDetailPosition,
|
||||||
airingNotice = detailsAiringNotice,
|
airingNotice = detailsAiringNotice,
|
||||||
onOpenItem = { related ->
|
onOpenItem = { related ->
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
@@ -3049,6 +3103,7 @@ private fun HomeScreen(
|
|||||||
itemId = related.id, itemType = related.type,
|
itemId = related.id, itemType = related.type,
|
||||||
)
|
)
|
||||||
detailsTrail = detailsTrail + selected
|
detailsTrail = detailsTrail + selected
|
||||||
|
restoreDetailPosition = false
|
||||||
// Ask for the full record the same way a focused card does. Some
|
// 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
|
// 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
|
// page's "Show" button knows only the series' id and name — and
|
||||||
@@ -3064,6 +3119,7 @@ private fun HomeScreen(
|
|||||||
onPlay = {
|
onPlay = {
|
||||||
detailsItem = null
|
detailsItem = null
|
||||||
detailsTrail = emptyList()
|
detailsTrail = emptyList()
|
||||||
|
restoreDetailPosition = false
|
||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
playItem(it)
|
playItem(it)
|
||||||
},
|
},
|
||||||
@@ -3123,13 +3179,31 @@ private fun HomeScreen(
|
|||||||
onClose = {
|
onClose = {
|
||||||
detailsItem = null
|
detailsItem = null
|
||||||
detailsTrail = emptyList()
|
detailsTrail = emptyList()
|
||||||
|
restoreDetailPosition = false
|
||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
requestFirstAvailableFocus(
|
||||||
|
cardReturnFocusRequester,
|
||||||
|
contentFocusRequester,
|
||||||
|
navigationFocusRequester,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
selectedMyShow?.let { show ->
|
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(
|
MyShowDetailsOverlay(
|
||||||
show = show,
|
show = show,
|
||||||
repository = repo,
|
repository = repo,
|
||||||
@@ -3139,12 +3213,12 @@ private fun HomeScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { repo.removeMyShow(show.itemId) }.onSuccess {
|
runCatching { repo.removeMyShow(show.itemId) }.onSuccess {
|
||||||
myShows = myShows.filterNot { it.itemId == show.itemId }
|
myShows = myShows.filterNot { it.itemId == show.itemId }
|
||||||
selectedMyShow = null
|
closeMyShow(true)
|
||||||
}
|
}
|
||||||
removingMyShow = false
|
removingMyShow = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClose = { selectedMyShow = null },
|
onClose = { closeMyShow(false) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showNotifications) {
|
if (showNotifications) {
|
||||||
@@ -3225,17 +3299,25 @@ private fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
quickMenuItem?.let { selected ->
|
quickMenuItem?.let { selected ->
|
||||||
val closeQuickActions: () -> Unit = {
|
val closeQuickActions: (Boolean) -> Unit = { originWillDisappear ->
|
||||||
quickMenuItem = null
|
quickMenuItem = null
|
||||||
quickMenuRowId = null
|
quickMenuRowId = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// The overlay owns focus until it leaves composition. Restore the
|
// The overlay owns focus until it leaves composition. Restore the
|
||||||
// exact originating card after its focus node is available again.
|
// exact originating card after its focus node is available again.
|
||||||
kotlinx.coroutines.delay(16L)
|
kotlinx.coroutines.delay(16L)
|
||||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
if (originWillDisappear) {
|
||||||
|
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||||
|
} else {
|
||||||
|
requestFirstAvailableFocus(
|
||||||
|
cardReturnFocusRequester,
|
||||||
|
contentFocusRequester,
|
||||||
|
navigationFocusRequester,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackHandler(onBack = closeQuickActions)
|
BackHandler { closeQuickActions(false) }
|
||||||
FocusedQuickActionsOverlay(
|
FocusedQuickActionsOverlay(
|
||||||
homeViewModel = homeViewModel,
|
homeViewModel = homeViewModel,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
@@ -3262,9 +3344,11 @@ private fun HomeScreen(
|
|||||||
} else {
|
} else {
|
||||||
contentFocusRequester
|
contentFocusRequester
|
||||||
}
|
}
|
||||||
if (runCatching { removalFocusRequester.requestFocus() }.isFailure) {
|
requestFirstAvailableFocus(
|
||||||
runCatching { navigationFocusRequester.requestFocus() }
|
removalFocusRequester,
|
||||||
}
|
contentFocusRequester,
|
||||||
|
navigationFocusRequester,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3283,7 +3367,7 @@ private fun HomeScreen(
|
|||||||
settings.homeHiddenRows.decodeRowIds().toSet(),
|
settings.homeHiddenRows.decodeRowIds().toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
closeQuickActions()
|
closeQuickActions(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHideRow = quickMenuRowId?.let { rowId ->
|
onHideRow = quickMenuRowId?.let { rowId ->
|
||||||
@@ -3296,8 +3380,8 @@ private fun HomeScreen(
|
|||||||
settings.homePinnedRows.decodeRowIds().toSet(),
|
settings.homePinnedRows.decodeRowIds().toSet(),
|
||||||
hidden,
|
hidden,
|
||||||
)
|
)
|
||||||
|
closeQuickActions(true)
|
||||||
}
|
}
|
||||||
closeQuickActions()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMoveRow = quickMenuRowId?.let { rowId ->
|
onMoveRow = quickMenuRowId?.let { rowId ->
|
||||||
@@ -3316,10 +3400,10 @@ private fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeQuickActions()
|
closeQuickActions(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClose = closeQuickActions,
|
onClose = { closeQuickActions(false) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
resolvingItem?.let { item ->
|
resolvingItem?.let { item ->
|
||||||
@@ -3645,6 +3729,7 @@ private fun FocusedHomeMetadata(
|
|||||||
private fun FocusedDetailsOverlay(
|
private fun FocusedDetailsOverlay(
|
||||||
homeViewModel: HomeViewModel,
|
homeViewModel: HomeViewModel,
|
||||||
selected: BaseItem,
|
selected: BaseItem,
|
||||||
|
restorePosition: Boolean,
|
||||||
onPlay: (BaseItem) -> Unit,
|
onPlay: (BaseItem) -> Unit,
|
||||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||||
isMyShow: Boolean,
|
isMyShow: Boolean,
|
||||||
@@ -3665,6 +3750,7 @@ private fun FocusedDetailsOverlay(
|
|||||||
onToggleMyShow = onToggleMyShow,
|
onToggleMyShow = onToggleMyShow,
|
||||||
onClose = onClose,
|
onClose = onClose,
|
||||||
onOpenItem = onOpenItem,
|
onOpenItem = onOpenItem,
|
||||||
|
restorePosition = restorePosition,
|
||||||
airingNotice = airingNotice,
|
airingNotice = airingNotice,
|
||||||
)
|
)
|
||||||
} else if (item.isEpisode) {
|
} else if (item.isEpisode) {
|
||||||
@@ -3678,6 +3764,7 @@ private fun FocusedDetailsOverlay(
|
|||||||
onTogglePlayed = onTogglePlayed,
|
onTogglePlayed = onTogglePlayed,
|
||||||
onClose = onClose,
|
onClose = onClose,
|
||||||
onOpenItem = onOpenItem,
|
onOpenItem = onOpenItem,
|
||||||
|
restorePosition = restorePosition,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MediaDetailsOverlay(
|
MediaDetailsOverlay(
|
||||||
@@ -3687,6 +3774,7 @@ private fun FocusedDetailsOverlay(
|
|||||||
onTogglePlayed = onTogglePlayed,
|
onTogglePlayed = onTogglePlayed,
|
||||||
onClose = onClose,
|
onClose = onClose,
|
||||||
onOpenItem = onOpenItem,
|
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) {
|
private fun openScreensaverSettings(context: android.content.Context) {
|
||||||
val candidates = listOf(
|
val candidates = listOf(
|
||||||
AndroidSettings.ACTION_DREAM_SETTINGS,
|
AndroidSettings.ACTION_DREAM_SETTINGS,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ fun MediaDetailsOverlay(
|
|||||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
|
restorePosition: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val settings by ServiceLocator.repository.settingsFlow
|
val settings by ServiceLocator.repository.settingsFlow
|
||||||
@@ -77,6 +78,7 @@ fun MediaDetailsOverlay(
|
|||||||
ratings = ratings,
|
ratings = ratings,
|
||||||
showRatingsStrip = settings.showRatingsStrip,
|
showRatingsStrip = settings.showRatingsStrip,
|
||||||
hideWatchedMovies = settings.hideWatchedMovies,
|
hideWatchedMovies = settings.hideWatchedMovies,
|
||||||
|
restorePosition = restorePosition,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,6 +100,7 @@ internal fun MediaDetailContent(
|
|||||||
showRatingsStrip: Boolean = true,
|
showRatingsStrip: Boolean = true,
|
||||||
hideWatchedMovies: Boolean = false,
|
hideWatchedMovies: Boolean = false,
|
||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
|
restorePosition: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
|
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
|
||||||
val credits = remember(item.id, item.people, item.genres) { creditRows(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) }
|
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||||
// Every newly opened title starts as a complete hero frame. Remembering a content
|
// 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.
|
// 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 selectedTab = detailTab(tabKey, tabs)
|
||||||
|
|
||||||
val play = remember(item.id) { FocusRequester() }
|
val play = remember(item.id) { FocusRequester() }
|
||||||
@@ -142,7 +147,7 @@ internal fun MediaDetailContent(
|
|||||||
)
|
)
|
||||||
RestoreDetailFocus(
|
RestoreDetailFocus(
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
zone = DetailZone.PLAY,
|
zone = if (restorePosition) remembered.zone else DetailZone.PLAY,
|
||||||
play = play,
|
play = play,
|
||||||
tabStrip = tabStrip,
|
tabStrip = tabStrip,
|
||||||
related = firstRelated,
|
related = firstRelated,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
|
||||||
package com.ponzischeme89.memby.ui
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bookmark
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -56,6 +61,8 @@ internal fun MyShowsStrip(
|
|||||||
density: String,
|
density: String,
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
contentFocusRequester: FocusRequester?,
|
contentFocusRequester: FocusRequester?,
|
||||||
|
returnFocusItemId: String? = null,
|
||||||
|
returnFocusRequester: FocusRequester? = null,
|
||||||
onShowSelected: (MyShow) -> Unit,
|
onShowSelected: (MyShow) -> Unit,
|
||||||
onContentFocused: () -> Unit,
|
onContentFocused: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -121,6 +128,13 @@ internal fun MyShowsStrip(
|
|||||||
Modifier
|
Modifier
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.then(
|
||||||
|
if (show.itemId == returnFocusItemId && returnFocusRequester != null) {
|
||||||
|
Modifier.focusRequester(returnFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
.focusProperties { left = navigationFocusRequester }
|
.focusProperties { left = navigationFocusRequester }
|
||||||
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
||||||
)
|
)
|
||||||
@@ -236,6 +250,20 @@ internal fun MyShowDetailsOverlay(
|
|||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onClose: () -> 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(
|
Box(
|
||||||
Modifier.fillMaxSize().zIndex(8f).background(MembySurface.copy(alpha = 0.96f)),
|
Modifier.fillMaxSize().zIndex(8f).background(MembySurface.copy(alpha = 0.96f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -264,11 +292,35 @@ internal fun MyShowDetailsOverlay(
|
|||||||
StatusLine("Sonarr", show.sonarrStatus)
|
StatusLine("Sonarr", show.sonarrStatus)
|
||||||
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
||||||
StatusLine("Series status", show.lifecycle)
|
StatusLine("Series status", show.lifecycle)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(
|
||||||
Button(onClick = onRemove, enabled = !removing) {
|
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")
|
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,
|
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
|
restorePosition: Boolean = false,
|
||||||
airingNotice: AiringNotice? = null,
|
airingNotice: AiringNotice? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -153,6 +154,7 @@ fun SeriesDetailsOverlay(
|
|||||||
showRatingsStrip = settings.showRatingsStrip,
|
showRatingsStrip = settings.showRatingsStrip,
|
||||||
hideWatchedMovies = settings.hideWatchedMovies,
|
hideWatchedMovies = settings.hideWatchedMovies,
|
||||||
onOpenItem = onOpenItem,
|
onOpenItem = onOpenItem,
|
||||||
|
restorePosition = restorePosition,
|
||||||
airingNotice = airingNotice,
|
airingNotice = airingNotice,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
@@ -175,6 +177,7 @@ internal fun SeriesDetailContent(
|
|||||||
showRatingsStrip: Boolean = true,
|
showRatingsStrip: Boolean = true,
|
||||||
hideWatchedMovies: Boolean = false,
|
hideWatchedMovies: Boolean = false,
|
||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
|
restorePosition: Boolean = false,
|
||||||
airingNotice: AiringNotice? = null,
|
airingNotice: AiringNotice? = null,
|
||||||
) {
|
) {
|
||||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||||
@@ -217,7 +220,9 @@ internal fun SeriesDetailContent(
|
|||||||
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
||||||
// A series opens on its cinematic hero and Overview just like a movie. Season and
|
// 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.
|
// 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 selectedTab = detailTab(tabKey, tabs)
|
||||||
|
|
||||||
val play = remember(item.id) { FocusRequester() }
|
val play = remember(item.id) { FocusRequester() }
|
||||||
@@ -265,7 +270,7 @@ internal fun SeriesDetailContent(
|
|||||||
)
|
)
|
||||||
RestoreDetailFocus(
|
RestoreDetailFocus(
|
||||||
itemId = item.id,
|
itemId = item.id,
|
||||||
zone = DetailZone.PLAY,
|
zone = if (restorePosition) remembered.zone else DetailZone.PLAY,
|
||||||
play = play,
|
play = play,
|
||||||
tabStrip = tabStrip,
|
tabStrip = tabStrip,
|
||||||
related = firstRelated,
|
related = firstRelated,
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ fun MyAlertsPage(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
|
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
val hasAlerts = notifications.isNotEmpty()
|
val hasAlerts = notifications.isNotEmpty()
|
||||||
LaunchedEffect(hasAlerts) {
|
LaunchedEffect(Unit) {
|
||||||
// One frame for the list to place its first row; an empty page has nothing below
|
// 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.
|
// the actions to land on, so the chips take the remote instead.
|
||||||
delay(16)
|
delay(16)
|
||||||
@@ -110,13 +110,18 @@ fun MyAlertsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(notificationIds) {
|
LaunchedEffect(notificationIds) {
|
||||||
|
if (notifications.isEmpty()) {
|
||||||
|
pendingFocusIndex = null
|
||||||
|
delay(16)
|
||||||
|
runCatching { actionsFocusRequester.requestFocus() }
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
|
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
|
||||||
// Spent on this list change however it turns out. Left set, a request that could
|
// 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 —
|
// 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
|
// 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.
|
// arriving on its own: focus would jump for a press made minutes ago.
|
||||||
pendingFocusIndex = null
|
pendingFocusIndex = null
|
||||||
if (notifications.isEmpty()) return@LaunchedEffect
|
|
||||||
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
|
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
|
||||||
?: return@LaunchedEffect
|
?: return@LaunchedEffect
|
||||||
runCatching { listState.scrollToItem(targetIndex) }
|
runCatching { listState.scrollToItem(targetIndex) }
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ internal fun CalendarContent(
|
|||||||
val selectedDay = week?.cells?.firstOrNull { it.date == state.selectedDate }
|
val selectedDay = week?.cells?.firstOrNull { it.date == state.selectedDate }
|
||||||
?: week?.cells?.firstOrNull { !it.isPad }
|
?: week?.cells?.firstOrNull { !it.isPad }
|
||||||
val selectedItems = remember(selectedDay) { selectedDay?.items.orEmpty().distinctItems() }
|
val selectedItems = remember(selectedDay) { selectedDay?.items.orEmpty().distinctItems() }
|
||||||
|
val monthFocusAnchor = calendarMonthFocusAnchor(
|
||||||
|
previousMonth = state.calendar.previous,
|
||||||
|
nextMonth = state.calendar.next,
|
||||||
|
)
|
||||||
|
|
||||||
val monthFocusRequester = remember { FocusRequester() }
|
val monthFocusRequester = remember { FocusRequester() }
|
||||||
val weekFocusRequester = remember { FocusRequester() }
|
val weekFocusRequester = remember { FocusRequester() }
|
||||||
@@ -162,6 +166,7 @@ internal fun CalendarContent(
|
|||||||
weekCount = weeks.size,
|
weekCount = weeks.size,
|
||||||
focusRequester = weekFocusRequester,
|
focusRequester = weekFocusRequester,
|
||||||
monthFocusRequester = monthFocusRequester,
|
monthFocusRequester = monthFocusRequester,
|
||||||
|
hasMonthControl = monthFocusAnchor != CalendarMonthFocusAnchor.NONE,
|
||||||
onPrevious = {
|
onPrevious = {
|
||||||
weeks.getOrNull(weekIndex - 1)?.let {
|
weeks.getOrNull(weekIndex - 1)?.let {
|
||||||
onSelectDate(calendarAgendaWeekDate(it))
|
onSelectDate(calendarAgendaWeekDate(it))
|
||||||
@@ -221,6 +226,7 @@ private fun AgendaHeader(
|
|||||||
weekFocusRequester: FocusRequester,
|
weekFocusRequester: FocusRequester,
|
||||||
onShowMonth: (String) -> Unit,
|
onShowMonth: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val monthFocusAnchor = calendarMonthFocusAnchor(previousMonth, nextMonth)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
||||||
@@ -244,7 +250,15 @@ private fun AgendaHeader(
|
|||||||
description = "Previous month",
|
description = "Previous month",
|
||||||
enabled = previousMonth.isNotEmpty(),
|
enabled = previousMonth.isNotEmpty(),
|
||||||
onClick = { onShowMonth(previousMonth) },
|
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))
|
Spacer(Modifier.width(8.dp))
|
||||||
AgendaIconButton(
|
AgendaIconButton(
|
||||||
@@ -253,12 +267,29 @@ private fun AgendaHeader(
|
|||||||
enabled = nextMonth.isNotEmpty(),
|
enabled = nextMonth.isNotEmpty(),
|
||||||
onClick = { onShowMonth(nextMonth) },
|
onClick = { onShowMonth(nextMonth) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(monthFocusRequester)
|
.then(
|
||||||
|
if (monthFocusAnchor == CalendarMonthFocusAnchor.NEXT) {
|
||||||
|
Modifier.focusRequester(monthFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
.focusProperties { down = weekFocusRequester },
|
.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
|
@Composable
|
||||||
private fun WeekSwitcher(
|
private fun WeekSwitcher(
|
||||||
week: CalendarAgendaWeek,
|
week: CalendarAgendaWeek,
|
||||||
@@ -266,6 +297,7 @@ private fun WeekSwitcher(
|
|||||||
weekCount: Int,
|
weekCount: Int,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
monthFocusRequester: FocusRequester,
|
monthFocusRequester: FocusRequester,
|
||||||
|
hasMonthControl: Boolean,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -283,7 +315,9 @@ private fun WeekSwitcher(
|
|||||||
contentDescription = "Week ${week.label}",
|
contentDescription = "Week ${week.label}",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.focusProperties { up = monthFocusRequester }
|
.focusProperties {
|
||||||
|
up = if (hasMonthControl) monthFocusRequester else FocusRequester.Default
|
||||||
|
}
|
||||||
.background(MembySurfaceRaised.copy(alpha = 0.62f), RoundedCornerShape(MembyChipCorner)),
|
.background(MembySurfaceRaised.copy(alpha = 0.62f), RoundedCornerShape(MembyChipCorner)),
|
||||||
) { focused ->
|
) { focused ->
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -1223,11 +1223,19 @@ private fun DeviceRenameDialog(
|
|||||||
val cancelFocus = remember { FocusRequester() }
|
val cancelFocus = remember { FocusRequester() }
|
||||||
val saveFocus = remember { FocusRequester() }
|
val saveFocus = remember { FocusRequester() }
|
||||||
val valid = name.trim().isNotEmpty() && name.length <= 80
|
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) {
|
LaunchedEffect(device.deviceId) {
|
||||||
delay(80L)
|
delay(80L)
|
||||||
runCatching { fieldFocus.requestFocus() }
|
runCatching { fieldFocus.requestFocus() }
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(busy) {
|
||||||
|
if (busy) {
|
||||||
|
delay(16L)
|
||||||
|
runCatching { cancelFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
|
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.78f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -1273,10 +1281,14 @@ private fun DeviceRenameDialog(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onCancel,
|
onClick = { if (!busy) onCancel() },
|
||||||
enabled = !busy,
|
// This remains the modal's stable focus owner while Save and the field
|
||||||
modifier = Modifier.focusRequester(cancelFocus).focusProperties { right = saveFocus },
|
// are disabled. Select is deliberately a no-op until the request ends.
|
||||||
) { Text("Cancel") }
|
enabled = true,
|
||||||
|
modifier = Modifier.focusRequester(cancelFocus).focusProperties {
|
||||||
|
right = if (busy) FocusRequester.Cancel else saveFocus
|
||||||
|
},
|
||||||
|
) { Text(if (busy) "Please wait…" else "Cancel") }
|
||||||
Button(
|
Button(
|
||||||
onClick = { onSave(name) },
|
onClick = { onSave(name) },
|
||||||
enabled = valid && !busy,
|
enabled = valid && !busy,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class UserSwitcherNavigationTest {
|
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
|
@Test
|
||||||
fun `active profile receives initial focus`() {
|
fun `active profile receives initial focus`() {
|
||||||
assertEquals(
|
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