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?) val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.34" val defaultVersionName = "0.3.35"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -1309,6 +1309,10 @@ class EmbyRepository internal constructor(
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss") 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. * 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, val leadDays: Int = 7,
) )
@Serializable
data class UserNotificationAction(
val kind: String = "",
val label: String = "",
val completedLabel: String = "",
val enabled: Boolean = true,
)
@Serializable @Serializable
data class UserNotification( data class UserNotification(
val id: Long = 0, val id: Long = 0,
@@ -1083,6 +1091,7 @@ data class UserNotification(
val eventAt: String? = null, val eventAt: String? = null,
val createdAt: String = "", val createdAt: String = "",
val readAt: String? = null, val readAt: String? = null,
val action: UserNotificationAction? = null,
) { ) {
val unread: Boolean get() = readAt.isNullOrBlank() 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. */ /** Requests the first focus target that is both attached and willing to accept focus. */
internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean { internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean =
requesters.forEach { requester -> requestFocusAmong(*requesters)
if (runCatching { requester.requestFocus() }.isSuccess) return true
}
return false
}
@@ -2575,6 +2575,34 @@ internal fun HomeScreen(
notificationsLoading = false 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 // 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 // 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. // 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.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color 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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.mark import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.delay
/** /**
* The shortcut menu behind a hold on the rail's user item. * The shortcut menu behind a hold on the rail's user item.
@@ -134,15 +134,14 @@ internal fun UserQuickActionsOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actions = userQuickActions(alertCount, busy) val actions = userQuickActions(alertCount, busy)
val focusRequesters = remember(actions.size) { List(actions.size) { FocusRequester() } } val actionIds = remember(actions) { actions.indices.toList() }
var focusedIndex by remember { mutableStateOf(0) } val focusRequesters = rememberFocusRequesterMap(actionIds)
// The menu appears while the confirm key is still physically held — that hold is what // 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 // opened it. Until it is released, swallow every activation so the press that raised the
// menu cannot also run the row it landed on. // menu cannot also run the row it landed on.
var openingPressReleased by remember { mutableStateOf(false) } var openingPressReleased by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(16) requestFocusWithRetries(focusRequesters[actionIds.firstOrNull()])
runCatching { focusRequesters.first().requestFocus() }
} }
Box( Box(
modifier modifier
@@ -173,19 +172,6 @@ internal fun UserQuickActionsOverlay(
} }
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) { 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 -> { Key.DirectionLeft, Key.Back -> {
onDismiss() onDismiss()
true true
@@ -223,8 +209,20 @@ internal fun UserQuickActionsOverlay(
}, },
enabled = action.enabled, enabled = action.enabled,
modifier = Modifier modifier = Modifier
.focusRequester(focusRequesters[index]) .focusRequester(focusRequesters.getValue(index))
.onFocusChanged { if (it.isFocused) focusedIndex = 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 = { onClick = {
when (action.kind) { when (action.kind) {
UserQuickActionKind.CLEAR_NOTIFICATIONS -> UserQuickActionKind.CLEAR_NOTIFICATIONS ->
@@ -63,6 +63,9 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.NotificationPreferences import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification 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.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyCardCorner
@@ -114,6 +117,7 @@ fun MyAlertsPage(
loading: Boolean = false, loading: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onNotificationAction: (UserNotification, UserNotificationAction) -> Unit = { _, _ -> },
onToggleSeen: (UserNotification) -> Unit = {}, onToggleSeen: (UserNotification) -> Unit = {},
onDismiss: (UserNotification) -> Unit, onDismiss: (UserNotification) -> Unit,
onDismissAll: (List<UserNotification>) -> Unit, onDismissAll: (List<UserNotification>) -> Unit,
@@ -132,10 +136,12 @@ fun MyAlertsPage(
val safePage = alertPageAfterChange(page, tabNotifications.size) val safePage = alertPageAfterChange(page, tabNotifications.size)
val pageNotifications = alertPageItems(tabNotifications, safePage) val pageNotifications = alertPageItems(tabNotifications, safePage)
val pageIds = pageNotifications.map(UserNotification::id) val pageIds = pageNotifications.map(UserNotification::id)
// Two requesters per row, because a row holds two focus targets and which of them a list // Separate requesters per row target, because a row holds the dismiss body plus one or
// change should land on depends on what the viewer just pressed — see [pendingFocusToggle]. // two side actions and which of them a list change should land on depends on what the
val rowFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } } // viewer just pressed — see [pendingFocusToggle].
val toggleFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } } val rowFocusRequesters = rememberFocusRequesterMap(pageIds)
val actionFocusRequesters = rememberFocusRequesterMap(pageIds)
val toggleFocusRequesters = rememberFocusRequesterMap(pageIds)
val tabsFocusRequester = remember { FocusRequester() } val tabsFocusRequester = remember { FocusRequester() }
val listState = rememberLazyListState() val listState = rememberLazyListState()
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) } var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
@@ -149,17 +155,15 @@ fun MyAlertsPage(
LaunchedEffect(tab) { LaunchedEffect(tab) {
// One frame for the list to place its first row; an empty pane has nothing below the // 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. // strip to land on, so the strip keeps the remote instead.
delay(16) requestFocusWithRetries(
runCatching { rowFocusRequesters[pageIds.firstOrNull()],
val first = rowFocusRequesters.firstOrNull() tabsFocusRequester,
if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus() )
}
} }
LaunchedEffect(pageIds) { LaunchedEffect(pageIds) {
if (!hasAlerts) { if (!hasAlerts) {
pendingFocusIndex = null pendingFocusIndex = null
delay(16) requestFocusWithRetries(tabsFocusRequester)
runCatching { tabsFocusRequester.requestFocus() }
return@LaunchedEffect return@LaunchedEffect
} }
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
@@ -177,11 +181,8 @@ fun MyAlertsPage(
alertFocusIndexAfterRemoval(requestedIndex, pageIds.size) alertFocusIndexAfterRemoval(requestedIndex, pageIds.size)
} ?: return@LaunchedEffect } ?: return@LaunchedEffect
runCatching { listState.scrollToItem(targetIndex) } runCatching { listState.scrollToItem(targetIndex) }
delay(16) val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters
runCatching { requestFocusWithRetries(targets[pageIds.getOrNull(targetIndex)], attempts = 3)
val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters
targets.getOrNull(targetIndex)?.requestFocus()
}
} }
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) { 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. // index: it is where the page opens and where Back returns to.
focusRequester = tabsFocusRequester.takeIf { entry == tab }, focusRequester = tabsFocusRequester.takeIf { entry == tab },
// Only ever pointed at a row that is actually placed this frame. // Only ever pointed at a row that is actually placed this frame.
paneFocusRequester = rowFocusRequesters.firstOrNull(), paneFocusRequester = rowFocusRequesters[pageIds.firstOrNull()],
onClick = { onClick = {
if (entry != tab) { if (entry != tab) {
page = 0 page = 0
@@ -258,11 +259,13 @@ fun MyAlertsPage(
index, notification -> index, notification ->
AlertRow( AlertRow(
notification = notification, notification = notification,
focusRequester = rowFocusRequesters[index], focusRequester = rowFocusRequesters.getValue(notification.id),
toggleFocusRequester = toggleFocusRequesters[index], actionFocusRequester = actionFocusRequesters.getValue(notification.id),
toggleFocusRequester = toggleFocusRequesters.getValue(notification.id),
// Up out of the top row reaches the strip. Only the first row // Up out of the top row reaches the strip. Only the first row
// states it; the rest are found by the ordinary focus search. // states it; the rest are found by the ordinary focus search.
upFocusRequester = tabsFocusRequester.takeIf { index == 0 }, upFocusRequester = tabsFocusRequester.takeIf { index == 0 },
onAction = { action -> onNotificationAction(notification, action) },
onToggleSeen = { onToggleSeen = {
// The row leaves this pane for the other one, so it needs the // The row leaves this pane for the other one, so it needs the
// same re-aim a dismissal does — landing on the toggle rather // same re-aim a dismissal does — landing on the toggle rather
@@ -443,6 +446,7 @@ private fun AlertsPillButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
iconLeading: Boolean = true, iconLeading: Boolean = true,
accented: Boolean = false, accented: Boolean = false,
enabled: Boolean = true,
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyChipCorner) val shape = RoundedCornerShape(MembyChipCorner)
@@ -457,7 +461,7 @@ private fun AlertsPillButton(
.background(if (focused) Color.White else Color.White.copy(alpha = 0.07f)) .background(if (focused) Color.White else Color.White.copy(alpha = 0.07f))
.border(1.dp, if (focused) Color.Transparent else MembyHairline, shape) .border(1.dp, if (focused) Color.Transparent else MembyHairline, shape)
.onFocusChanged { focused = it.isFocused } .onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick) .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
.semantics { contentDescription = label } .semantics { contentDescription = label }
.padding(horizontal = 10.dp, vertical = 6.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -683,11 +687,10 @@ private fun ringSwing(progress: Float): Float {
/** /**
* One notification. * One notification.
* *
* The row holds **two** focus targets rather than one, and the split is what makes a seen * The row holds the dismiss press plus its side actions as separate focus targets, which is
* toggle possible at all on a remote with a single confirm key. The body keeps the press it * what makes "remove from My Shows" and the seen toggle possible at all on a remote with one
* always had OK dismisses, with the hint stated on the row about to go and Right reaches * confirm key. The body keeps the press it always had OK dismisses, with the hint stated on
* a toggle beside it. Down still moves to the next row from either, so the second target * the row about to go and Right reaches the action buttons beside it.
* costs nothing to somebody walking the list who never wants it.
* *
* The lit surface belongs to the whole row, driven by `hasFocus` rather than `isFocused`, so * 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. * 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( private fun AlertRow(
notification: UserNotification, notification: UserNotification,
focusRequester: FocusRequester, focusRequester: FocusRequester,
actionFocusRequester: FocusRequester,
toggleFocusRequester: FocusRequester, toggleFocusRequester: FocusRequester,
upFocusRequester: FocusRequester?, upFocusRequester: FocusRequester?,
onAction: (UserNotificationAction) -> Unit,
onToggleSeen: () -> Unit, onToggleSeen: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, 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( AlertsPillButton(
modifier = Modifier.focusRequester(toggleFocusRequester), modifier = Modifier.focusRequester(toggleFocusRequester),
label = alertSeenActionLabel(notification.unread), label = alertSeenActionLabel(notification.unread),
@@ -77,6 +77,9 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard 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.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentDeep import com.ponzischeme89.memby.ui.theme.MembyAccentDeep
import com.ponzischeme89.memby.ui.theme.MembyAccentInk 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 // 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, // 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. // for a moment, two, and the one it answers with is not the one under the marker.
val railFocusRequesters = remember(itemType) { val railFocusRequesters = rememberFocusRequesterMap(
genreCategoryTabs(itemType).associate { it.id to FocusRequester() } genreCategoryTabs(itemType).map(GenreCategory::id),
} )
// Same shape, one per service icon. The Services strip is not itemType-specific, so // 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. // 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. */ /** Every requester the remote can land on across both strips, for a lookup by id. */
val allEntryFocusRequesters = railFocusRequesters + serviceFocusRequesters 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. * remember where the viewer was, and the genre they chose is the only honest answer.
*/ */
fun focusRail(): Boolean = fun focusRail(): Boolean =
allEntryFocusRequesters[activeCategoryId]?.let { runCatching { it.requestFocus() }.isSuccess } requestFocusAmong(
?: runCatching { contentFocusRequester.requestFocus() }.isSuccess allEntryFocusRequesters[activeCategoryId],
contentFocusRequester,
)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// The rail's items are one composition away, so the first attempt can legitimately requestFocusWithRetries(
// find nothing attached — hence the retries rather than a single delayed request. allEntryFocusRequesters[activeCategoryId],
repeat(4) { contentFocusRequester,
kotlinx.coroutines.delay(32L) attempts = 4,
if (focusRail()) return@LaunchedEffect frameDelayMillis = 32L,
} )
} }
// Back steps out of the grid before it steps off the page — one press per level, the // 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. // 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 // 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. // have moved to that card first, which is one recomposition away.
runCatching { gridState.scrollToItem(remembered) } runCatching { gridState.scrollToItem(remembered) }
repeat(3) { requestFocusWithRetries(gridEntryFocusRequester, attempts = 3)
kotlinx.coroutines.delay(16L)
if (runCatching { gridEntryFocusRequester.requestFocus() }.isSuccess) return@launch
}
} }
return true return true
} }
@@ -1068,4 +1072,4 @@ private fun GenreRetry(
) )
} }
} }
} }
@@ -42,7 +42,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState 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.NavigationLabels
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -226,15 +224,14 @@ fun MediaQuickActionsOverlay(
} }
val actions = itemActions + rowActions + val actions = itemActions + rowActions +
QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose) QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose)
val actionCount = actions.size val actionIds = remember(item.id, actions) { actions.indices.toList() }
val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } } val actionCount = actionIds.size
var focusedIndex by remember(item.id) { mutableIntStateOf(0) } val focusRequesters = rememberFocusRequesterMap(actionIds)
// The menu can appear while OK is still physically held. Until that opening press // 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. // is released, consume all activation events so it cannot trigger the first action.
var openingPressReleased by remember(item.id) { mutableStateOf(false) } var openingPressReleased by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(item.id) { LaunchedEffect(item.id) {
delay(16.milliseconds) requestFocusWithRetries(focusRequesters[actionIds.firstOrNull()])
runCatching { focusRequesters.first().requestFocus() }
} }
Box( Box(
Modifier Modifier
@@ -267,20 +264,6 @@ fun MediaQuickActionsOverlay(
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
} }
when (event.key) { 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 -> { Key.DirectionLeft, Key.Back -> {
onClose() onClose()
true true
@@ -334,8 +317,20 @@ fun MediaQuickActionsOverlay(
label = action.label, label = action.label,
icon = action.icon, icon = action.icon,
modifier = Modifier modifier = Modifier
.focusRequester(focusRequesters[index]) .focusRequester(focusRequesters.getValue(index))
.onFocusChanged { if (it.isFocused) focusedIndex = 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, 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.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.mark import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES } enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
@@ -147,13 +146,11 @@ internal fun MediaRow(
val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
rowState.scrollToItem(entryIndex) rowState.scrollToItem(entryIndex)
repeat(6) { requestFocusWithRetries(
delay(16.milliseconds) verticalEntryFocusRequester,
if (verticalEntryFocusRequester.requestFocusIfAttached()) { attempts = 6,
currentOnVerticalFocusRequestConsumed(request.requestId) frameDelayMillis = 16.milliseconds.inWholeMilliseconds,
return@LaunchedEffect )
}
}
currentOnVerticalFocusRequestConsumed(request.requestId) 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 enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private fun cardFormat( 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.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface 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 @Composable
internal fun SearchGenres( internal fun SearchGenres(
genres: List<String>, genres: List<String>,
@@ -64,7 +64,6 @@ internal fun SearchGenres(
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) { ) {
itemsIndexed(genres.take(6), key = { _, genre -> genre }) { index, genre -> itemsIndexed(genres.take(6), key = { _, genre -> genre }) { index, genre ->
val colour = GenreColours[index % GenreColours.size]
FocusScaleContainer( FocusScaleContainer(
onFocused = onFocused, onFocused = onFocused,
onClick = { onSelected(genre) }, onClick = { onSelected(genre) },
@@ -90,13 +89,19 @@ internal fun SearchGenres(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(if (focused) colour.copy(alpha = 0.98f) else colour) .background(
when {
focused -> GenreChipFocusedBackground
selected -> MembyAccent.copy(alpha = 0.22f)
else -> GenreChipBackground
},
)
.border( .border(
if (focused || selected) 2.dp else 1.dp, if (focused || selected) 2.dp else 1.dp,
when { when {
focused -> Color.White focused -> Color.White
selected -> MembyAccent selected -> MembyAccent
else -> Color.White.copy(alpha = 0.10f) else -> Color.White
}, },
GenreShape, GenreShape,
) )
@@ -130,13 +135,5 @@ internal fun SearchGenres(
} }
private val GenreShape = RoundedCornerShape(10.dp) private val GenreShape = RoundedCornerShape(10.dp)
private val GenreChipBackground = Color(0xFF050505)
/** Dark, desaturated counterparts of Search's established genre palette. */ private val GenreChipFocusedBackground = Color(0xFF111111)
private val GenreColours = listOf(
Color(0xFF4A332C),
Color(0xFF303B50),
Color(0xFF3D334B),
Color(0xFF263F3B),
Color(0xFF4A3A29),
Color(0xFF49303A),
)
@@ -103,6 +103,8 @@ import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.ui.PreviewSurface import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle 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.MembyTrialTypography
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright import com.ponzischeme89.memby.ui.theme.MembyAccentBright
@@ -474,8 +476,11 @@ fun SettingsSheet(
var shown by remember { mutableStateOf(false) } var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
shown = true shown = true
delay(170) requestFocusWithRetries(
runCatching { firstFocus.requestFocus() } firstFocus,
attempts = 3,
frameDelayMillis = 85L,
)
} }
val state = SettingsPanelState( 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. // page list; this makes Up say the same thing once the pane has no row above.
val railSelectionFocusRequester = remember { FocusRequester() } val railSelectionFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
LaunchedEffect(state.selectedPage) { LaunchedEffect(state.selectedPage) {
contentScrollState.scrollTo(0) contentScrollState.scrollTo(0)
@@ -828,16 +834,18 @@ internal fun SettingsPanelContent(
// Deliberately not focusProperties { up = … }, which every row in the // Deliberately not focusProperties { up = … }, which every row in the
// pane inherits and which would take a page's own vertical navigation // 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 // 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 // finds nothing anywhere, and finding nothing is the whole case. The edge
// move the default handler would have made is made here first, and the // has to be owned in preview dispatch because by the time a child bubbles a
// rail is reached only when it fails — which is the top control and // failed Up here, Compose has already given up on moving focus.
// nothing else. .onPreviewKeyEvent { event ->
.onKeyEvent { event ->
when { when {
event.type != KeyEventType.KeyDown -> false event.type != KeyEventType.KeyDown -> false
event.key != Key.DirectionUp -> false event.key != Key.DirectionUp -> false
focusManager.moveFocus(FocusDirection.Up) -> true focusManager.moveFocus(FocusDirection.Up) -> true
else -> runCatching { railSelectionFocusRequester.requestFocus() }.isSuccess else -> {
scope.launch { requestFocusWithRetries(railSelectionFocusRequester) }
true
}
} }
} }
.verticalScroll(contentScrollState) .verticalScroll(contentScrollState)
@@ -849,6 +857,7 @@ internal fun SettingsPanelContent(
), ),
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp), verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
) { ) {
val pageEntryModifier = Modifier.focusProperties { up = railSelectionFocusRequester }
SettingsHeader(page = state.selectedPage) SettingsHeader(page = state.selectedPage)
when (state.selectedPage) { when (state.selectedPage) {
SettingsPage.APPEARANCE -> SettingsGroup { SettingsPage.APPEARANCE -> SettingsGroup {
@@ -869,6 +878,7 @@ internal fun SettingsPanelContent(
locked = state.themeLocked, locked = state.themeLocked,
notice = state.themeNotice, notice = state.themeNotice,
onSelected = actions.onThemeChanged, onSelected = actions.onThemeChanged,
modifier = pageEntryModifier,
) )
SettingDivider() SettingDivider()
} }
@@ -877,6 +887,11 @@ internal fun SettingsPanelContent(
description = "Use a film or show's own logo instead of plain text.", description = "Use a film or show's own logo instead of plain text.",
checked = state.showLogo, checked = state.showLogo,
onCheckedChange = actions.onShowLogoChanged, onCheckedChange = actions.onShowLogoChanged,
modifier = if (THEME_PICKER_ENABLED && state.themeOptions.size > 1) {
Modifier
} else {
pageEntryModifier
},
) )
SettingDivider() SettingDivider()
SettingsChoiceRow( SettingsChoiceRow(
@@ -916,6 +931,7 @@ internal fun SettingsPanelContent(
onSelected = { value -> onSelected = { value ->
actions.onAudioPassthroughModeChanged(AudioPassthroughMode.from(value)) actions.onAudioPassthroughModeChanged(AudioPassthroughMode.from(value))
}, },
modifier = pageEntryModifier,
) )
if (state.audioPassthroughMode == AudioPassthroughMode.MANUAL) { if (state.audioPassthroughMode == AudioPassthroughMode.MANUAL) {
SurroundCodec.entries.forEach { codec -> SurroundCodec.entries.forEach { codec ->
@@ -995,6 +1011,7 @@ internal fun SettingsPanelContent(
description = copy.second, description = copy.second,
checked = key in state.homeSections, checked = key in state.homeSections,
onCheckedChange = { actions.onHomeSectionChanged(key, it) }, onCheckedChange = { actions.onHomeSectionChanged(key, it) },
modifier = if (index == 0) pageEntryModifier else Modifier,
) )
} }
SettingDivider() SettingDivider()
@@ -1066,6 +1083,7 @@ internal fun SettingsPanelContent(
onRename = { actions.onRenameDevice(device) }, onRename = { actions.onRenameDevice(device) },
onRemove = { actions.onRemoveDevice(device) }, onRemove = { actions.onRemoveDevice(device) },
onCancelRemove = actions.onCancelDeviceRemoval, 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.", description = "Check which TVs are signed in right now.",
badge = "REFRESH", badge = "REFRESH",
onClick = actions.onRefreshDevices, onClick = actions.onRefreshDevices,
modifier = if (state.devices.isEmpty()) pageEntryModifier else Modifier,
) )
} }
SettingsPage.STORAGE -> SettingsGroup { SettingsPage.STORAGE -> SettingsGroup {
@@ -1099,6 +1118,7 @@ internal fun SettingsPanelContent(
else -> formatCacheSize(cache.totalBytes).uppercase() else -> formatCacheSize(cache.totalBytes).uppercase()
}, },
onClick = actions.onClearImageCache, onClick = actions.onClearImageCache,
modifier = pageEntryModifier,
) )
// Only the confirmation gets a tinted band. A standing explanation in // Only the confirmation gets a tinted band. A standing explanation in
// the notice colour would be the loudest thing on a page whose whole // the notice colour would be the loudest thing on a page whose whole
@@ -1160,9 +1180,10 @@ private fun DeviceManagementRow(
onRename: () -> Unit, onRename: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onCancelRemove: () -> Unit, onCancelRemove: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -1242,13 +1263,15 @@ private fun DeviceRenameDialog(
// lets Back close the whole Settings destination behind the still-running rename. // lets Back close the whole Settings destination behind the still-running rename.
BackHandler { if (!busy) onCancel() } BackHandler { if (!busy) onCancel() }
LaunchedEffect(device.deviceId) { LaunchedEffect(device.deviceId) {
delay(80L) requestFocusWithRetries(
runCatching { fieldFocus.requestFocus() } fieldFocus,
attempts = 3,
frameDelayMillis = 40L,
)
} }
LaunchedEffect(busy) { LaunchedEffect(busy) {
if (busy) { if (busy) {
delay(16L) requestFocusWithRetries(cancelFocus)
runCatching { cancelFocus.requestFocus() }
} }
} }
Box( Box(
@@ -1360,12 +1383,18 @@ private fun SettingsSecondaryRail(
// bounds happen to be closer (notably below Playback on a 540p viewport). Give // 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 // every vertical move an explicit destination so focus cannot leak through the
// settings surface and activate home content. // 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 -> pages.map { page ->
if (page == selectedRailPage && firstFocusRequester != null) { if (page == selectedRailPage && firstFocusRequester != null) {
firstFocusRequester firstFocusRequester
} else { } else {
FocusRequester() retainedRailFocusRequesters.getValue(page.name)
} }
} }
} }
@@ -1464,8 +1493,12 @@ private fun SettingsSecondaryRail(
// first, then hand focus to it once it has composed. // first, then hand focus to it once it has composed.
onSelected(page) onSelected(page)
scope.launch { scope.launch {
delay(32L) requestFocusWithRetries(
runCatching { contentFocusRequester.requestFocus() } contentFocusRequester,
attempts = 3,
frameDelayMillis = 16L,
initialDelayMillis = 16L,
)
} }
true true
} }
@@ -1685,9 +1718,10 @@ private fun SettingsColourSchemeRow(
locked: Boolean, locked: Boolean,
notice: String, notice: String,
onSelected: (String) -> Unit, onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp), modifier = modifier.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -1738,9 +1772,10 @@ private fun SettingsChoiceRow(
options: List<ChoiceOption>, options: List<ChoiceOption>,
selected: String, selected: String,
onSelected: (String) -> Unit, onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp), modifier = modifier.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.alerts package com.ponzischeme89.memby.ui.alerts
import com.ponzischeme89.memby.data.model.UserNotification import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.UserNotificationAction
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
@@ -80,6 +81,17 @@ class AlertsFormatTest {
assertEquals(listOf(9L, 4L, 6L), alertsForTab(AlertsTab.INBOX, notifications).map { it.id }) 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 @Test
fun `a tab count is drawn as itself, zero included`() { fun `a tab count is drawn as itself, zero included`() {
assertEquals("0", alertTabCountLabel(0)) 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 * 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 * 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 { type notificationsResponse struct {
Notifications []store.UserNotification `json:"notifications"` Notifications []notificationResponse `json:"notifications"`
Preferences store.NotificationPreferences `json:"preferences"` 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) { func (s *Server) handleMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) {
switch r.Method { switch r.Method {
case http.MethodGet: 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") writeError(w, http.StatusInternalServerError, "could not load notifications")
return 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{ writeJSON(w, http.StatusOK, notificationsResponse{
Notifications: filterStoredNotifications(notifications, prefs), Notifications: notificationResponses(visible, shows, sonarrSeries),
Preferences: prefs, 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( func (s *Server) syncReturnNotifications(
r *http.Request, sess store.Session, prefs store.NotificationPreferences, 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) err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id)
case "dismiss": case "dismiss":
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id) err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
case "remove-my-show":
err = s.removeMyShowFromNotification(r, sess, id)
default: default:
writeError(w, http.StatusNotFound, "unknown notification action") writeError(w, http.StatusNotFound, "unknown notification action")
return return
@@ -325,6 +399,34 @@ func (s *Server) handleNotificationAction(
w.WriteHeader(http.StatusNoContent) 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 { func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields() decoder.DisallowUnknownFields()
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/ponzischeme89/memby/server/internal/appupdate" "github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "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) 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, Source: notifySourceSonarrLifecycle,
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
Title: "Show cancelled", Title: cancelledNotificationTitle(change.Current.Title),
Body: change.Current.Title + " has been cancelled.", Body: cancelledNotificationBody(change.Current.Title),
SourceKey: sourceKey, SourceKey: sourceKey,
EventAt: &eventAt, EventAt: &eventAt,
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status}, Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
@@ -163,6 +163,14 @@ func sonarrLifecycleNotificationKind(previous, current string) string {
return "" 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 { func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 { if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID) return "tvdb:" + strconv.Itoa(series.TVDBID)
+3 -2
View File
@@ -42,6 +42,7 @@ func DefaultNotificationPreferences() NotificationPreferences {
type UserNotification struct { type UserNotification struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
SourceKey string `json:"-"`
ItemID string `json:"itemId,omitempty"` ItemID string `json:"itemId,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Message string `json:"message"` Message string `json:"message"`
@@ -345,7 +346,7 @@ func (s *Store) UpsertNotification(
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) { func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
rows, err := s.pool.Query(ctx, ` 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 FROM user_notifications
WHERE emby_user_id = $1 AND dismissed_at IS NULL WHERE emby_user_id = $1 AND dismissed_at IS NULL
ORDER BY created_at DESC LIMIT 100`, userID) 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() { for rows.Next() {
var notification UserNotification var notification UserNotification
if err := rows.Scan( 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.Title, &notification.Message, &notification.EventAt,
&notification.CreatedAt, &notification.ReadAt, &notification.CreatedAt, &notification.ReadAt,
); err != nil { ); err != nil {