This commit is contained in:
ponzischeme89
2026-08-27 15:50:15 +12:00
parent 6b2bf767e2
commit 2f4bd0da31
19 changed files with 456 additions and 144 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.34"
val defaultVersionName = "0.3.35"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -1309,6 +1309,10 @@ class EmbyRepository internal constructor(
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
}
suspend fun runNotificationAction(id: Long, action: String) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, action)
}
/**
* Clears this viewer's notifications in one request and answers how many were taken.
*
@@ -1073,6 +1073,14 @@ data class NotificationPreferences(
val leadDays: Int = 7,
)
@Serializable
data class UserNotificationAction(
val kind: String = "",
val label: String = "",
val completedLabel: String = "",
val enabled: Boolean = true,
)
@Serializable
data class UserNotification(
val id: Long = 0,
@@ -1083,6 +1091,7 @@ data class UserNotification(
val eventAt: String? = null,
val createdAt: String = "",
val readAt: String? = null,
val action: UserNotificationAction? = null,
) {
val unread: Boolean get() = readAt.isNullOrBlank()
}
@@ -0,0 +1,50 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.FocusRequester
import kotlinx.coroutines.delay
/**
* One requester per semantic key, retained while that key remains on screen.
*
* Lazy content commonly refreshes with a new list instance for the same ids. Rebuilding the
* requesters on every emission leaves focus restoration racing composition for no benefit.
*/
@Composable
internal fun <K> rememberFocusRequesterMap(keys: Collection<K>): Map<K, FocusRequester> {
val retainedRequesters = remember { mutableMapOf<K, FocusRequester>() }
val orderedKeys = remember(keys) { keys.distinct() }
val validKeys = orderedKeys.toSet()
retainedRequesters.keys.retainAll(validKeys)
orderedKeys.forEach { key ->
retainedRequesters.getOrPut(key) { FocusRequester() }
}
return orderedKeys.associateWith(retainedRequesters::getValue)
}
/** Requests the first focus target that is both attached and willing to accept focus. */
internal fun requestFocusAmong(vararg requesters: FocusRequester?): Boolean {
requesters.forEach { requester ->
if (requester != null && runCatching { requester.requestFocus() }.isSuccess) return true
}
return false
}
/**
* Retries focus hand-off across a few frames for targets owned by lazy content or an overlay
* underneath the one that just closed.
*/
internal suspend fun requestFocusWithRetries(
vararg requesters: FocusRequester?,
attempts: Int = 4,
frameDelayMillis: Long = 16L,
initialDelayMillis: Long = 0L,
): Boolean {
if (initialDelayMillis > 0L) delay(initialDelayMillis)
repeat(attempts.coerceAtLeast(1)) { attempt ->
if (attempt > 0) delay(frameDelayMillis)
if (requestFocusAmong(*requesters)) return true
}
return false
}
@@ -1070,13 +1070,8 @@ internal fun personalisedFavouritesTitle(username: String?): String {
}
/** Requests the first focus target that is both attached and willing to accept focus. */
internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean {
requesters.forEach { requester ->
if (runCatching { requester.requestFocus() }.isSuccess) return true
}
return false
}
internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean =
requestFocusAmong(*requesters)
@@ -2575,6 +2575,34 @@ internal fun HomeScreen(
notificationsLoading = false
}
},
onNotificationAction = { notification, action ->
if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true
val previous = notificationState
notificationState = notificationState.copy(
notifications = notificationState.notifications.map {
if (it.id == notification.id) {
it.copy(
itemId = "",
action = action.copy(
label = action.completedLabel.ifBlank { action.label },
enabled = false,
),
)
} else {
it
}
},
)
scope.launch {
runCatching { repo.runNotificationAction(notification.id, action.kind) }
.onFailure { failure ->
notificationState = previous
notificationsError = friendlyEmbyError(failure)
}
notificationsMutationBusy = false
}
},
// The seen toggle, and the only thing that moves a row between Inbox and
// Seen — nothing is marked read merely by being looked at any more, because
// with the two halves split that would empty the Inbox under the remote.
@@ -2835,4 +2863,3 @@ internal fun HomeScreen(
)
}
}
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
@@ -51,7 +52,6 @@ import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.delay
/**
* The shortcut menu behind a hold on the rail's user item.
@@ -134,15 +134,14 @@ internal fun UserQuickActionsOverlay(
modifier: Modifier = Modifier,
) {
val actions = userQuickActions(alertCount, busy)
val focusRequesters = remember(actions.size) { List(actions.size) { FocusRequester() } }
var focusedIndex by remember { mutableStateOf(0) }
val actionIds = remember(actions) { actions.indices.toList() }
val focusRequesters = rememberFocusRequesterMap(actionIds)
// The menu appears while the confirm key is still physically held — that hold is what
// opened it. Until it is released, swallow every activation so the press that raised the
// menu cannot also run the row it landed on.
var openingPressReleased by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(16)
runCatching { focusRequesters.first().requestFocus() }
requestFocusWithRetries(focusRequesters[actionIds.firstOrNull()])
}
Box(
modifier
@@ -173,19 +172,6 @@ internal fun UserQuickActionsOverlay(
}
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionUp, Key.DirectionDown -> {
focusedIndex = quickActionNextIndex(
currentIndex = focusedIndex,
actionCount = actions.size,
direction = if (event.key == Key.DirectionUp) {
QuickActionDirection.UP
} else {
QuickActionDirection.DOWN
},
)
runCatching { focusRequesters[focusedIndex].requestFocus() }
true
}
Key.DirectionLeft, Key.Back -> {
onDismiss()
true
@@ -223,8 +209,20 @@ internal fun UserQuickActionsOverlay(
},
enabled = action.enabled,
modifier = Modifier
.focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = index },
.focusRequester(focusRequesters.getValue(index))
.focusProperties {
up = if (index == 0) {
FocusRequester.Cancel
} else {
focusRequesters.getValue(index - 1)
}
down = if (index == actionIds.lastIndex) {
FocusRequester.Cancel
} else {
focusRequesters.getValue(index + 1)
}
right = FocusRequester.Cancel
},
onClick = {
when (action.kind) {
UserQuickActionKind.CLEAR_NOTIFICATIONS ->
@@ -63,6 +63,9 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.UserNotificationAction
import com.ponzischeme89.memby.ui.rememberFocusRequesterMap
import com.ponzischeme89.memby.ui.requestFocusWithRetries
import com.ponzischeme89.memby.ui.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
@@ -114,6 +117,7 @@ fun MyAlertsPage(
loading: Boolean = false,
errorMessage: String? = null,
onRetry: () -> Unit = {},
onNotificationAction: (UserNotification, UserNotificationAction) -> Unit = { _, _ -> },
onToggleSeen: (UserNotification) -> Unit = {},
onDismiss: (UserNotification) -> Unit,
onDismissAll: (List<UserNotification>) -> Unit,
@@ -132,10 +136,12 @@ fun MyAlertsPage(
val safePage = alertPageAfterChange(page, tabNotifications.size)
val pageNotifications = alertPageItems(tabNotifications, safePage)
val pageIds = pageNotifications.map(UserNotification::id)
// Two requesters per row, because a row holds two focus targets and which of them a list
// change should land on depends on what the viewer just pressed — see [pendingFocusToggle].
val rowFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
val toggleFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
// Separate requesters per row target, because a row holds the dismiss body plus one or
// two side actions and which of them a list change should land on depends on what the
// viewer just pressed — see [pendingFocusToggle].
val rowFocusRequesters = rememberFocusRequesterMap(pageIds)
val actionFocusRequesters = rememberFocusRequesterMap(pageIds)
val toggleFocusRequesters = rememberFocusRequesterMap(pageIds)
val tabsFocusRequester = remember { FocusRequester() }
val listState = rememberLazyListState()
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
@@ -149,17 +155,15 @@ fun MyAlertsPage(
LaunchedEffect(tab) {
// One frame for the list to place its first row; an empty pane has nothing below the
// strip to land on, so the strip keeps the remote instead.
delay(16)
runCatching {
val first = rowFocusRequesters.firstOrNull()
if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus()
}
requestFocusWithRetries(
rowFocusRequesters[pageIds.firstOrNull()],
tabsFocusRequester,
)
}
LaunchedEffect(pageIds) {
if (!hasAlerts) {
pendingFocusIndex = null
delay(16)
runCatching { tabsFocusRequester.requestFocus() }
requestFocusWithRetries(tabsFocusRequester)
return@LaunchedEffect
}
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
@@ -177,11 +181,8 @@ fun MyAlertsPage(
alertFocusIndexAfterRemoval(requestedIndex, pageIds.size)
} ?: return@LaunchedEffect
runCatching { listState.scrollToItem(targetIndex) }
delay(16)
runCatching {
val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters
targets.getOrNull(targetIndex)?.requestFocus()
}
requestFocusWithRetries(targets[pageIds.getOrNull(targetIndex)], attempts = 3)
}
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
@@ -207,7 +208,7 @@ fun MyAlertsPage(
// index: it is where the page opens and where Back returns to.
focusRequester = tabsFocusRequester.takeIf { entry == tab },
// Only ever pointed at a row that is actually placed this frame.
paneFocusRequester = rowFocusRequesters.firstOrNull(),
paneFocusRequester = rowFocusRequesters[pageIds.firstOrNull()],
onClick = {
if (entry != tab) {
page = 0
@@ -258,11 +259,13 @@ fun MyAlertsPage(
index, notification ->
AlertRow(
notification = notification,
focusRequester = rowFocusRequesters[index],
toggleFocusRequester = toggleFocusRequesters[index],
focusRequester = rowFocusRequesters.getValue(notification.id),
actionFocusRequester = actionFocusRequesters.getValue(notification.id),
toggleFocusRequester = toggleFocusRequesters.getValue(notification.id),
// Up out of the top row reaches the strip. Only the first row
// states it; the rest are found by the ordinary focus search.
upFocusRequester = tabsFocusRequester.takeIf { index == 0 },
onAction = { action -> onNotificationAction(notification, action) },
onToggleSeen = {
// The row leaves this pane for the other one, so it needs the
// same re-aim a dismissal does — landing on the toggle rather
@@ -443,6 +446,7 @@ private fun AlertsPillButton(
modifier: Modifier = Modifier,
iconLeading: Boolean = true,
accented: Boolean = false,
enabled: Boolean = true,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyChipCorner)
@@ -457,7 +461,7 @@ private fun AlertsPillButton(
.background(if (focused) Color.White else Color.White.copy(alpha = 0.07f))
.border(1.dp, if (focused) Color.Transparent else MembyHairline, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
.semantics { contentDescription = label }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -683,11 +687,10 @@ private fun ringSwing(progress: Float): Float {
/**
* One notification.
*
* The row holds **two** focus targets rather than one, and the split is what makes a seen
* toggle possible at all on a remote with a single confirm key. The body keeps the press it
* always had OK dismisses, with the hint stated on the row about to go and Right reaches
* a toggle beside it. Down still moves to the next row from either, so the second target
* costs nothing to somebody walking the list who never wants it.
* The row holds the dismiss press plus its side actions as separate focus targets, which is
* what makes "remove from My Shows" and the seen toggle possible at all on a remote with one
* confirm key. The body keeps the press it always had OK dismisses, with the hint stated on
* the row about to go and Right reaches the action buttons beside it.
*
* The lit surface belongs to the whole row, driven by `hasFocus` rather than `isFocused`, so
* a row does not go dark the moment the remote steps sideways into its own toggle.
@@ -696,8 +699,10 @@ private fun ringSwing(progress: Float): Float {
private fun AlertRow(
notification: UserNotification,
focusRequester: FocusRequester,
actionFocusRequester: FocusRequester,
toggleFocusRequester: FocusRequester,
upFocusRequester: FocusRequester?,
onAction: (UserNotificationAction) -> Unit,
onToggleSeen: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
@@ -799,6 +804,18 @@ private fun AlertRow(
}
}
}
notification.action?.let { action ->
AlertsPillButton(
modifier = Modifier.focusRequester(actionFocusRequester),
label = if (action.enabled) action.label else {
action.completedLabel.ifBlank { action.label }
},
icon = if (action.enabled) MembyIcon.PlaylistRemove.mark else MembyIcon.CheckCircle.mark,
onClick = { onAction(action) },
accented = action.enabled,
enabled = action.enabled,
)
}
AlertsPillButton(
modifier = Modifier.focusRequester(toggleFocusRequester),
label = alertSeenActionLabel(notification.unread),
@@ -77,6 +77,9 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.rememberFocusRequesterMap
import com.ponzischeme89.memby.ui.requestFocusAmong
import com.ponzischeme89.memby.ui.requestFocusWithRetries
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentDeep
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
@@ -174,13 +177,15 @@ fun GenreBrowseScreen(
// was what put a returning viewer on the genre they had passed through rather than on
// the one they had chosen: a requester that changes which node it is attached to has,
// for a moment, two, and the one it answers with is not the one under the marker.
val railFocusRequesters = remember(itemType) {
genreCategoryTabs(itemType).associate { it.id to FocusRequester() }
}
val railFocusRequesters = rememberFocusRequesterMap(
genreCategoryTabs(itemType).map(GenreCategory::id),
)
// Same shape, one per service icon. The Services strip is not itemType-specific, so
// this is stable for the life of the screen rather than keyed to it.
val serviceFocusRequesters = remember { serviceCategoryTabs().associate { it.id to FocusRequester() } }
val serviceFocusRequesters = rememberFocusRequesterMap(
serviceCategoryTabs().map(GenreCategory::id),
)
/** Every requester the remote can land on across both strips, for a lookup by id. */
val allEntryFocusRequesters = railFocusRequesters + serviceFocusRequesters
@@ -226,16 +231,18 @@ fun GenreBrowseScreen(
* remember where the viewer was, and the genre they chose is the only honest answer.
*/
fun focusRail(): Boolean =
allEntryFocusRequesters[activeCategoryId]?.let { runCatching { it.requestFocus() }.isSuccess }
?: runCatching { contentFocusRequester.requestFocus() }.isSuccess
requestFocusAmong(
allEntryFocusRequesters[activeCategoryId],
contentFocusRequester,
)
LaunchedEffect(Unit) {
// The rail's items are one composition away, so the first attempt can legitimately
// find nothing attached — hence the retries rather than a single delayed request.
repeat(4) {
kotlinx.coroutines.delay(32L)
if (focusRail()) return@LaunchedEffect
}
requestFocusWithRetries(
allEntryFocusRequesters[activeCategoryId],
contentFocusRequester,
attempts = 4,
frameDelayMillis = 32L,
)
}
// Back steps out of the grid before it steps off the page — one press per level, the
// rule the search pane and the calendar already follow.
@@ -264,10 +271,7 @@ fun GenreBrowseScreen(
// back where it was before its card is asked for — and the requester has to
// have moved to that card first, which is one recomposition away.
runCatching { gridState.scrollToItem(remembered) }
repeat(3) {
kotlinx.coroutines.delay(16L)
if (runCatching { gridEntryFocusRequester.requestFocus() }.isSuccess) return@launch
}
requestFocusWithRetries(gridEntryFocusRequester, attempts = 3)
}
return true
}
@@ -42,7 +42,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@@ -116,7 +115,6 @@ import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@@ -226,15 +224,14 @@ fun MediaQuickActionsOverlay(
}
val actions = itemActions + rowActions +
QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose)
val actionCount = actions.size
val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } }
var focusedIndex by remember(item.id) { mutableIntStateOf(0) }
val actionIds = remember(item.id, actions) { actions.indices.toList() }
val actionCount = actionIds.size
val focusRequesters = rememberFocusRequesterMap(actionIds)
// The menu can appear while OK is still physically held. Until that opening press
// is released, consume all activation events so it cannot trigger the first action.
var openingPressReleased by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(item.id) {
delay(16.milliseconds)
runCatching { focusRequesters.first().requestFocus() }
requestFocusWithRetries(focusRequesters[actionIds.firstOrNull()])
}
Box(
Modifier
@@ -267,20 +264,6 @@ fun MediaQuickActionsOverlay(
return@onPreviewKeyEvent false
}
when (event.key) {
Key.DirectionUp, Key.DirectionDown -> {
val direction = if (event.key == Key.DirectionUp) {
QuickActionDirection.UP
} else {
QuickActionDirection.DOWN
}
focusedIndex = quickActionNextIndex(
currentIndex = focusedIndex,
actionCount = actionCount,
direction = direction,
)
runCatching { focusRequesters[focusedIndex].requestFocus() }
true
}
Key.DirectionLeft, Key.Back -> {
onClose()
true
@@ -334,8 +317,20 @@ fun MediaQuickActionsOverlay(
label = action.label,
icon = action.icon,
modifier = Modifier
.focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = index },
.focusRequester(focusRequesters.getValue(index))
.focusProperties {
up = if (index == 0) {
FocusRequester.Cancel
} else {
focusRequesters.getValue(index - 1)
}
down = if (index == actionCount - 1) {
FocusRequester.Cancel
} else {
focusRequesters.getValue(index + 1)
}
right = FocusRequester.Cancel
},
onClick = action.onClick,
)
}
@@ -50,7 +50,6 @@ import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
@@ -147,13 +146,11 @@ internal fun MediaRow(
val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
rowState.scrollToItem(entryIndex)
repeat(6) {
delay(16.milliseconds)
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
currentOnVerticalFocusRequestConsumed(request.requestId)
return@LaunchedEffect
}
}
requestFocusWithRetries(
verticalEntryFocusRequester,
attempts = 6,
frameDelayMillis = 16.milliseconds.inWholeMilliseconds,
)
currentOnVerticalFocusRequestConsumed(request.requestId)
}
@@ -317,9 +314,6 @@ internal fun MediaRow(
}
}
private fun FocusRequester.requestFocusIfAttached(): Boolean =
runCatching { requestFocus() }.getOrDefault(false)
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private fun cardFormat(
@@ -30,7 +30,7 @@ import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
/** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */
/** Search's compact discovery strip. Unselected chips sit on an OLED-black treatment. */
@Composable
internal fun SearchGenres(
genres: List<String>,
@@ -64,7 +64,6 @@ internal fun SearchGenres(
modifier = Modifier.padding(top = 8.dp),
) {
itemsIndexed(genres.take(6), key = { _, genre -> genre }) { index, genre ->
val colour = GenreColours[index % GenreColours.size]
FocusScaleContainer(
onFocused = onFocused,
onClick = { onSelected(genre) },
@@ -90,13 +89,19 @@ internal fun SearchGenres(
Box(
Modifier
.fillMaxSize()
.background(if (focused) colour.copy(alpha = 0.98f) else colour)
.background(
when {
focused -> GenreChipFocusedBackground
selected -> MembyAccent.copy(alpha = 0.22f)
else -> GenreChipBackground
},
)
.border(
if (focused || selected) 2.dp else 1.dp,
when {
focused -> Color.White
selected -> MembyAccent
else -> Color.White.copy(alpha = 0.10f)
else -> Color.White
},
GenreShape,
)
@@ -130,13 +135,5 @@ internal fun SearchGenres(
}
private val GenreShape = RoundedCornerShape(10.dp)
/** Dark, desaturated counterparts of Search's established genre palette. */
private val GenreColours = listOf(
Color(0xFF4A332C),
Color(0xFF303B50),
Color(0xFF3D334B),
Color(0xFF263F3B),
Color(0xFF4A3A29),
Color(0xFF49303A),
)
private val GenreChipBackground = Color(0xFF050505)
private val GenreChipFocusedBackground = Color(0xFF111111)
@@ -103,6 +103,8 @@ import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.ui.rememberFocusRequesterMap
import com.ponzischeme89.memby.ui.requestFocusWithRetries
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
@@ -474,8 +476,11 @@ fun SettingsSheet(
var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
shown = true
delay(170)
runCatching { firstFocus.requestFocus() }
requestFocusWithRetries(
firstFocus,
attempts = 3,
frameDelayMillis = 85L,
)
}
val state = SettingsPanelState(
@@ -782,6 +787,7 @@ internal fun SettingsPanelContent(
// page list; this makes Up say the same thing once the pane has no row above.
val railSelectionFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
LaunchedEffect(state.selectedPage) {
contentScrollState.scrollTo(0)
@@ -828,16 +834,18 @@ internal fun SettingsPanelContent(
// Deliberately not focusProperties { up = … }, which every row in the
// pane inherits and which would take a page's own vertical navigation
// away from it; and not `exit`, which is never consulted when the search
// finds nothing anywhere, and finding nothing is the whole case. So the
// move the default handler would have made is made here first, and the
// rail is reached only when it fails — which is the top control and
// nothing else.
.onKeyEvent { event ->
// finds nothing anywhere, and finding nothing is the whole case. The edge
// has to be owned in preview dispatch because by the time a child bubbles a
// failed Up here, Compose has already given up on moving focus.
.onPreviewKeyEvent { event ->
when {
event.type != KeyEventType.KeyDown -> false
event.key != Key.DirectionUp -> false
focusManager.moveFocus(FocusDirection.Up) -> true
else -> runCatching { railSelectionFocusRequester.requestFocus() }.isSuccess
else -> {
scope.launch { requestFocusWithRetries(railSelectionFocusRequester) }
true
}
}
}
.verticalScroll(contentScrollState)
@@ -849,6 +857,7 @@ internal fun SettingsPanelContent(
),
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
) {
val pageEntryModifier = Modifier.focusProperties { up = railSelectionFocusRequester }
SettingsHeader(page = state.selectedPage)
when (state.selectedPage) {
SettingsPage.APPEARANCE -> SettingsGroup {
@@ -869,6 +878,7 @@ internal fun SettingsPanelContent(
locked = state.themeLocked,
notice = state.themeNotice,
onSelected = actions.onThemeChanged,
modifier = pageEntryModifier,
)
SettingDivider()
}
@@ -877,6 +887,11 @@ internal fun SettingsPanelContent(
description = "Use a film or show's own logo instead of plain text.",
checked = state.showLogo,
onCheckedChange = actions.onShowLogoChanged,
modifier = if (THEME_PICKER_ENABLED && state.themeOptions.size > 1) {
Modifier
} else {
pageEntryModifier
},
)
SettingDivider()
SettingsChoiceRow(
@@ -916,6 +931,7 @@ internal fun SettingsPanelContent(
onSelected = { value ->
actions.onAudioPassthroughModeChanged(AudioPassthroughMode.from(value))
},
modifier = pageEntryModifier,
)
if (state.audioPassthroughMode == AudioPassthroughMode.MANUAL) {
SurroundCodec.entries.forEach { codec ->
@@ -995,6 +1011,7 @@ internal fun SettingsPanelContent(
description = copy.second,
checked = key in state.homeSections,
onCheckedChange = { actions.onHomeSectionChanged(key, it) },
modifier = if (index == 0) pageEntryModifier else Modifier,
)
}
SettingDivider()
@@ -1066,6 +1083,7 @@ internal fun SettingsPanelContent(
onRename = { actions.onRenameDevice(device) },
onRemove = { actions.onRemoveDevice(device) },
onCancelRemove = actions.onCancelDeviceRemoval,
modifier = if (index == 0) pageEntryModifier else Modifier,
)
}
}
@@ -1075,6 +1093,7 @@ internal fun SettingsPanelContent(
description = "Check which TVs are signed in right now.",
badge = "REFRESH",
onClick = actions.onRefreshDevices,
modifier = if (state.devices.isEmpty()) pageEntryModifier else Modifier,
)
}
SettingsPage.STORAGE -> SettingsGroup {
@@ -1099,6 +1118,7 @@ internal fun SettingsPanelContent(
else -> formatCacheSize(cache.totalBytes).uppercase()
},
onClick = actions.onClearImageCache,
modifier = pageEntryModifier,
)
// Only the confirmation gets a tinted band. A standing explanation in
// the notice colour would be the loudest thing on a page whose whole
@@ -1160,9 +1180,10 @@ private fun DeviceManagementRow(
onRename: () -> Unit,
onRemove: () -> Unit,
onCancelRemove: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -1242,13 +1263,15 @@ private fun DeviceRenameDialog(
// lets Back close the whole Settings destination behind the still-running rename.
BackHandler { if (!busy) onCancel() }
LaunchedEffect(device.deviceId) {
delay(80L)
runCatching { fieldFocus.requestFocus() }
requestFocusWithRetries(
fieldFocus,
attempts = 3,
frameDelayMillis = 40L,
)
}
LaunchedEffect(busy) {
if (busy) {
delay(16L)
runCatching { cancelFocus.requestFocus() }
requestFocusWithRetries(cancelFocus)
}
}
Box(
@@ -1360,12 +1383,18 @@ private fun SettingsSecondaryRail(
// bounds happen to be closer (notably below Playback on a 540p viewport). Give
// every vertical move an explicit destination so focus cannot leak through the
// settings surface and activate home content.
val railFocusRequesters = remember(firstFocusRequester) {
val retainedRailFocusRequesters = rememberFocusRequesterMap(pages.map(SettingsPage::name))
val railFocusRequesters = remember(
pages,
selectedRailPage,
firstFocusRequester,
retainedRailFocusRequesters,
) {
pages.map { page ->
if (page == selectedRailPage && firstFocusRequester != null) {
firstFocusRequester
} else {
FocusRequester()
retainedRailFocusRequesters.getValue(page.name)
}
}
}
@@ -1464,8 +1493,12 @@ private fun SettingsSecondaryRail(
// first, then hand focus to it once it has composed.
onSelected(page)
scope.launch {
delay(32L)
runCatching { contentFocusRequester.requestFocus() }
requestFocusWithRetries(
contentFocusRequester,
attempts = 3,
frameDelayMillis = 16L,
initialDelayMillis = 16L,
)
}
true
}
@@ -1685,9 +1718,10 @@ private fun SettingsColourSchemeRow(
locked: Boolean,
notice: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
modifier = modifier.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -1738,9 +1772,10 @@ private fun SettingsChoiceRow(
options: List<ChoiceOption>,
selected: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
modifier = modifier.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.alerts
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.UserNotificationAction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -80,6 +81,17 @@ class AlertsFormatTest {
assertEquals(listOf(9L, 4L, 6L), alertsForTab(AlertsTab.INBOX, notifications).map { it.id })
}
@Test
fun `notification action keeps its completion label for the immediate UI update`() {
val action = UserNotificationAction(
kind = "remove-my-show",
label = "Remove from My Shows",
completedLabel = "Removed from My Shows",
)
assertEquals("Remove from My Shows", action.label)
assertEquals("Removed from My Shows", action.completedLabel)
}
@Test
fun `a tab count is drawn as itself, zero included`() {
assertEquals("0", alertTabCountLabel(0))
@@ -142,6 +142,27 @@ class AlertsPageScreenshotTest {
)
}
@Test
fun `cancelled show with remove action`() {
capture(
"my-alerts-cancelled-remove-action",
listOf(
UserNotification(
id = 1,
kind = "show-cancelled",
itemId = "emby-1",
title = "The Peripheral - Cancelled",
message = "The Peripheral has been cancelled by the network.",
action = com.ponzischeme89.memby.data.model.UserNotificationAction(
kind = "remove-my-show",
label = "Remove from My Shows",
completedLabel = "Removed from My Shows",
),
),
),
)
}
/**
* The way in: the user menu in the user picker, with the badge that replaced the bell
* on the launcher. Captured here rather than beside the profile switcher's own tests
+104 -2
View File
@@ -26,10 +26,21 @@ type myShowsResponse struct {
}
type notificationsResponse struct {
Notifications []store.UserNotification `json:"notifications"`
Notifications []notificationResponse `json:"notifications"`
Preferences store.NotificationPreferences `json:"preferences"`
}
type notificationAction struct {
Kind string `json:"kind"`
Label string `json:"label"`
CompletedLabel string `json:"completedLabel"`
}
type notificationResponse struct {
store.UserNotification
Action *notificationAction `json:"action,omitempty"`
}
func (s *Server) handleMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) {
switch r.Method {
case http.MethodGet:
@@ -178,12 +189,73 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
writeError(w, http.StatusInternalServerError, "could not load notifications")
return
}
visible := filterStoredNotifications(notifications, prefs)
shows := []store.UserShow{}
if value, showsErr := s.store.UserShows(r.Context(), sess.EmbyUserID); showsErr == nil {
shows = value
} else {
s.loggerFor(r.Context()).Warn("My Shows unavailable for notification actions", "error", showsErr)
}
sonarrSeries := []sonarr.Series{}
if s.sonarrEnabled(r.Context()) {
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
sonarrSeries = value
} else {
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for notifications", "error", seriesErr)
}
}
writeJSON(w, http.StatusOK, notificationsResponse{
Notifications: filterStoredNotifications(notifications, prefs),
Notifications: notificationResponses(visible, shows, sonarrSeries),
Preferences: prefs,
})
}
func notificationResponses(
notifications []store.UserNotification, shows []store.UserShow, series []sonarr.Series,
) []notificationResponse {
showItemsBySeriesKey := map[string]string{}
for _, show := range shows {
matched := matchSonarrSeries(show, series)
if matched == nil {
continue
}
seriesKey := sonarrSeriesStatusKey(*matched)
if seriesKey == "" || strings.TrimSpace(show.ItemID) == "" {
continue
}
showItemsBySeriesKey[seriesKey] = show.ItemID
}
result := make([]notificationResponse, 0, len(notifications))
for _, notification := range notifications {
response := notificationResponse{UserNotification: notification}
if notification.Kind == "show-cancelled" {
if itemID := showItemsBySeriesKey[notificationSeriesKey(notification.SourceKey)]; itemID != "" {
response.ItemID = itemID
response.Action = &notificationAction{
Kind: "remove-my-show",
Label: "Remove from My Shows",
CompletedLabel: "Removed from My Shows",
}
}
}
result = append(result, response)
}
return result
}
func notificationSeriesKey(sourceKey string) string {
const prefix = "show-cancelled:"
if !strings.HasPrefix(sourceKey, prefix) {
return ""
}
trimmed := strings.TrimPrefix(sourceKey, prefix)
cut := strings.LastIndex(trimmed, ":")
if cut <= 0 {
return ""
}
return trimmed[:cut]
}
func (s *Server) syncReturnNotifications(
r *http.Request, sess store.Session, prefs store.NotificationPreferences,
) {
@@ -314,6 +386,8 @@ func (s *Server) handleNotificationAction(
err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id)
case "dismiss":
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
case "remove-my-show":
err = s.removeMyShowFromNotification(r, sess, id)
default:
writeError(w, http.StatusNotFound, "unknown notification action")
return
@@ -325,6 +399,34 @@ func (s *Server) handleNotificationAction(
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) removeMyShowFromNotification(
r *http.Request, sess store.Session, notificationID int64,
) error {
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
if err != nil {
return err
}
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
if err != nil {
return err
}
series := []sonarr.Series{}
if s.sonarrEnabled(r.Context()) {
value, err := s.sonarrSeriesCatalogue(r.Context())
if err != nil {
return err
}
series = value
}
for _, notification := range notificationResponses(notifications, shows, series) {
if notification.ID != notificationID || notification.Action == nil || notification.ItemID == "" {
continue
}
return s.store.DeleteUserShow(r.Context(), sess.EmbyUserID, notification.ItemID)
}
return nil
}
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -81,3 +82,45 @@ func TestClearableNotificationIDsHonourPreferences(t *testing.T) {
t.Fatalf("an empty list should clear nothing, got %v", ids)
}
}
func TestNotificationResponsesShowRemoveMyShowsActionOnlyForCurrentFollow(t *testing.T) {
shows := []store.UserShow{
{ItemID: "emby-1", Title: "The Peripheral", Year: intPtr(2022)},
{ItemID: "emby-2", Title: "Shogun", Year: intPtr(2024)},
}
series := []sonarr.Series{
{ID: 10, TVDBID: 123, Title: "The Peripheral", Year: 2022},
{ID: 11, TVDBID: 456, Title: "Shogun", Year: 2024},
}
notifications := []store.UserNotification{
{ID: 1, Kind: "show-cancelled", SourceKey: "show-cancelled:tvdb:123:9", Title: "The Peripheral - Cancelled"},
{ID: 2, Kind: "show-cancelled", SourceKey: "show-cancelled:tvdb:999:8", Title: "Unknown Show - Cancelled"},
{ID: 3, Kind: "show-return", SourceKey: "show-return:emby-2:2026-08-27", Title: "Shogun returns"},
}
got := notificationResponses(notifications, shows, series)
if got[0].Action == nil {
t.Fatalf("cancelled followed show should offer an action: %#v", got[0])
}
if got[0].Action.Kind != "remove-my-show" || got[0].Action.Label != "Remove from My Shows" {
t.Fatalf("unexpected action = %#v", got[0].Action)
}
if got[0].ItemID != "emby-1" {
t.Fatalf("cancelled followed show item = %q, want emby-1", got[0].ItemID)
}
if got[1].Action != nil {
t.Fatalf("unfollowed cancelled show should not offer an action: %#v", got[1])
}
if got[2].Action != nil {
t.Fatalf("non-cancelled notification should not offer an action: %#v", got[2])
}
}
func TestCancelledNotificationCopy(t *testing.T) {
if got := cancelledNotificationTitle("The Peripheral"); got != "The Peripheral - Cancelled" {
t.Fatalf("title = %q", got)
}
if got := cancelledNotificationBody("The Peripheral"); got != "The Peripheral has been cancelled by the network." {
t.Fatalf("body = %q", got)
}
}
+10 -2
View File
@@ -131,8 +131,8 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) (sonarrLifecycleResult
Source: notifySourceSonarrLifecycle,
UserID: user.ID,
Username: user.Username,
Title: "Show cancelled",
Body: change.Current.Title + " has been cancelled.",
Title: cancelledNotificationTitle(change.Current.Title),
Body: cancelledNotificationBody(change.Current.Title),
SourceKey: sourceKey,
EventAt: &eventAt,
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
@@ -163,6 +163,14 @@ func sonarrLifecycleNotificationKind(previous, current string) string {
return ""
}
func cancelledNotificationTitle(title string) string {
return strings.TrimSpace(title) + " - Cancelled"
}
func cancelledNotificationBody(title string) string {
return strings.TrimSpace(title) + " has been cancelled by the network."
}
func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID)
+3 -2
View File
@@ -42,6 +42,7 @@ func DefaultNotificationPreferences() NotificationPreferences {
type UserNotification struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
SourceKey string `json:"-"`
ItemID string `json:"itemId,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
@@ -345,7 +346,7 @@ func (s *Store) UpsertNotification(
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, kind, item_id, title, message, event_at, created_at, read_at
SELECT id, kind, source_key, item_id, title, message, event_at, created_at, read_at
FROM user_notifications
WHERE emby_user_id = $1 AND dismissed_at IS NULL
ORDER BY created_at DESC LIMIT 100`, userID)
@@ -357,7 +358,7 @@ func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNot
for rows.Next() {
var notification UserNotification
if err := rows.Scan(
&notification.ID, &notification.Kind, &notification.ItemID,
&notification.ID, &notification.Kind, &notification.SourceKey, &notification.ItemID,
&notification.Title, &notification.Message, &notification.EventAt,
&notification.CreatedAt, &notification.ReadAt,
); err != nil {