From 2f4bd0da318efc44eb2b7247df7f9b2380e8e7cf Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 27 Aug 2026 15:50:15 +1200 Subject: [PATCH] 0.3.35 --- app/build.gradle.kts | 2 +- .../memby/data/EmbyRepository.kt | 4 + .../memby/data/model/GatewayModels.kt | 9 ++ .../ponzischeme89/memby/ui/FocusNavigation.kt | 50 +++++++++ .../ponzischeme89/memby/ui/HomeOverlays.kt | 9 +- .../com/ponzischeme89/memby/ui/HomeScreen.kt | 29 ++++- .../memby/ui/UserQuickActions.kt | 38 +++---- .../memby/ui/alerts/AlertsPage.kt | 67 ++++++----- .../memby/ui/genre/GenreBrowseScreen.kt | 38 ++++--- .../memby/ui/home/HomeQuickActions.kt | 41 +++---- .../ponzischeme89/memby/ui/home/HomeRow.kt | 16 +-- .../ui/search/components/SearchGenres.kt | 25 ++--- .../memby/ui/settings/SettingsSheet.kt | 73 ++++++++---- .../memby/ui/alerts/AlertsFormatTest.kt | 12 ++ .../ui/alerts/AlertsPageScreenshotTest.kt | 21 ++++ server/internal/api/my_shows.go | 106 +++++++++++++++++- .../api/notification_preferences_test.go | 43 +++++++ server/internal/api/sonarr_status.go | 12 +- server/internal/store/my_shows.go | 5 +- 19 files changed, 456 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/FocusNavigation.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6932c8..e7f9ff7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 09a60f6..ee65ce7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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. * diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index a69b4bb..a3d1d51 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -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() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/FocusNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/FocusNavigation.kt new file mode 100644 index 0000000..40e530c --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/FocusNavigation.kt @@ -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 rememberFocusRequesterMap(keys: Collection): Map { + val retainedRequesters = remember { mutableMapOf() } + 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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index ed85261..4c23d38 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 06caf4c..31b38a7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -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( ) } } - diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserQuickActions.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserQuickActions.kt index c7ceb6e..3d0774c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UserQuickActions.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserQuickActions.kt @@ -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 -> diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index bd0f357..45444cc 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -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) -> 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(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() - } + val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters + 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), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index ec08dfa..a79779d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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 } @@ -1068,4 +1072,4 @@ private fun GenreRetry( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt index 3b9264e..b03e4f4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeQuickActions.kt @@ -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, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt index 52f05ab..50fe115 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt @@ -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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt index 634d9d9..a8f10c7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt @@ -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, @@ -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) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 8c5c8fb..313d174 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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, 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)) { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt index eb423a7..08f0577 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt @@ -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)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt index fcfaf8c..6081b11 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt @@ -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 diff --git a/server/internal/api/my_shows.go b/server/internal/api/my_shows.go index 34b3770..a63a70a 100644 --- a/server/internal/api/my_shows.go +++ b/server/internal/api/my_shows.go @@ -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 = ¬ificationAction{ + 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() diff --git a/server/internal/api/notification_preferences_test.go b/server/internal/api/notification_preferences_test.go index 935a90c..38d9844 100644 --- a/server/internal/api/notification_preferences_test.go +++ b/server/internal/api/notification_preferences_test.go @@ -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) + } +} diff --git a/server/internal/api/sonarr_status.go b/server/internal/api/sonarr_status.go index 94993c8..fc1a8f9 100644 --- a/server/internal/api/sonarr_status.go +++ b/server/internal/api/sonarr_status.go @@ -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) diff --git a/server/internal/store/my_shows.go b/server/internal/store/my_shows.go index eb2e98d..1625e21 100644 --- a/server/internal/store/my_shows.go +++ b/server/internal/store/my_shows.go @@ -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( - ¬ification.ID, ¬ification.Kind, ¬ification.ItemID, + ¬ification.ID, ¬ification.Kind, ¬ification.SourceKey, ¬ification.ItemID, ¬ification.Title, ¬ification.Message, ¬ification.EventAt, ¬ification.CreatedAt, ¬ification.ReadAt, ); err != nil {