0.3.35
This commit is contained in:
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.34"
|
||||
val defaultVersionName = "0.3.35"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -1309,6 +1309,10 @@ class EmbyRepository internal constructor(
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
|
||||
}
|
||||
|
||||
suspend fun runNotificationAction(id: Long, action: String) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears this viewer's notifications in one request and answers how many were taken.
|
||||
*
|
||||
|
||||
@@ -1073,6 +1073,14 @@ data class NotificationPreferences(
|
||||
val leadDays: Int = 7,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserNotificationAction(
|
||||
val kind: String = "",
|
||||
val label: String = "",
|
||||
val completedLabel: String = "",
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserNotification(
|
||||
val id: Long = 0,
|
||||
@@ -1083,6 +1091,7 @@ data class UserNotification(
|
||||
val eventAt: String? = null,
|
||||
val createdAt: String = "",
|
||||
val readAt: String? = null,
|
||||
val action: UserNotificationAction? = null,
|
||||
) {
|
||||
val unread: Boolean get() = readAt.isNullOrBlank()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* One requester per semantic key, retained while that key remains on screen.
|
||||
*
|
||||
* Lazy content commonly refreshes with a new list instance for the same ids. Rebuilding the
|
||||
* requesters on every emission leaves focus restoration racing composition for no benefit.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <K> rememberFocusRequesterMap(keys: Collection<K>): Map<K, FocusRequester> {
|
||||
val retainedRequesters = remember { mutableMapOf<K, FocusRequester>() }
|
||||
val orderedKeys = remember(keys) { keys.distinct() }
|
||||
val validKeys = orderedKeys.toSet()
|
||||
retainedRequesters.keys.retainAll(validKeys)
|
||||
orderedKeys.forEach { key ->
|
||||
retainedRequesters.getOrPut(key) { FocusRequester() }
|
||||
}
|
||||
return orderedKeys.associateWith(retainedRequesters::getValue)
|
||||
}
|
||||
|
||||
/** Requests the first focus target that is both attached and willing to accept focus. */
|
||||
internal fun requestFocusAmong(vararg requesters: FocusRequester?): Boolean {
|
||||
requesters.forEach { requester ->
|
||||
if (requester != null && runCatching { requester.requestFocus() }.isSuccess) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries focus hand-off across a few frames for targets owned by lazy content or an overlay
|
||||
* underneath the one that just closed.
|
||||
*/
|
||||
internal suspend fun requestFocusWithRetries(
|
||||
vararg requesters: FocusRequester?,
|
||||
attempts: Int = 4,
|
||||
frameDelayMillis: Long = 16L,
|
||||
initialDelayMillis: Long = 0L,
|
||||
): Boolean {
|
||||
if (initialDelayMillis > 0L) delay(initialDelayMillis)
|
||||
repeat(attempts.coerceAtLeast(1)) { attempt ->
|
||||
if (attempt > 0) delay(frameDelayMillis)
|
||||
if (requestFocusAmong(*requesters)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1070,13 +1070,8 @@ internal fun personalisedFavouritesTitle(username: String?): String {
|
||||
}
|
||||
|
||||
/** Requests the first focus target that is both attached and willing to accept focus. */
|
||||
internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean {
|
||||
requesters.forEach { requester ->
|
||||
if (runCatching { requester.requestFocus() }.isSuccess) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun requestFirstAvailableFocus(vararg requesters: FocusRequester): Boolean =
|
||||
requestFocusAmong(*requesters)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2575,6 +2575,34 @@ internal fun HomeScreen(
|
||||
notificationsLoading = false
|
||||
}
|
||||
},
|
||||
onNotificationAction = { notification, action ->
|
||||
if (notificationsMutationBusy) return@MyAlertsPage
|
||||
notificationsMutationBusy = true
|
||||
val previous = notificationState
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.map {
|
||||
if (it.id == notification.id) {
|
||||
it.copy(
|
||||
itemId = "",
|
||||
action = action.copy(
|
||||
label = action.completedLabel.ifBlank { action.label },
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { repo.runNotificationAction(notification.id, action.kind) }
|
||||
.onFailure { failure ->
|
||||
notificationState = previous
|
||||
notificationsError = friendlyEmbyError(failure)
|
||||
}
|
||||
notificationsMutationBusy = false
|
||||
}
|
||||
},
|
||||
// The seen toggle, and the only thing that moves a row between Inbox and
|
||||
// Seen — nothing is marked read merely by being looked at any more, because
|
||||
// with the two halves split that would empty the Inbox under the remote.
|
||||
@@ -2835,4 +2863,3 @@ internal fun HomeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -51,7 +52,6 @@ import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The shortcut menu behind a hold on the rail's user item.
|
||||
@@ -134,15 +134,14 @@ internal fun UserQuickActionsOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actions = userQuickActions(alertCount, busy)
|
||||
val focusRequesters = remember(actions.size) { List(actions.size) { FocusRequester() } }
|
||||
var focusedIndex by remember { mutableStateOf(0) }
|
||||
val actionIds = remember(actions) { actions.indices.toList() }
|
||||
val focusRequesters = rememberFocusRequesterMap(actionIds)
|
||||
// The menu appears while the confirm key is still physically held — that hold is what
|
||||
// opened it. Until it is released, swallow every activation so the press that raised the
|
||||
// menu cannot also run the row it landed on.
|
||||
var openingPressReleased by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(16)
|
||||
runCatching { focusRequesters.first().requestFocus() }
|
||||
requestFocusWithRetries(focusRequesters[actionIds.firstOrNull()])
|
||||
}
|
||||
Box(
|
||||
modifier
|
||||
@@ -173,19 +172,6 @@ internal fun UserQuickActionsOverlay(
|
||||
}
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
focusedIndex = quickActionNextIndex(
|
||||
currentIndex = focusedIndex,
|
||||
actionCount = actions.size,
|
||||
direction = if (event.key == Key.DirectionUp) {
|
||||
QuickActionDirection.UP
|
||||
} else {
|
||||
QuickActionDirection.DOWN
|
||||
},
|
||||
)
|
||||
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
||||
true
|
||||
}
|
||||
Key.DirectionLeft, Key.Back -> {
|
||||
onDismiss()
|
||||
true
|
||||
@@ -223,8 +209,20 @@ internal fun UserQuickActionsOverlay(
|
||||
},
|
||||
enabled = action.enabled,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
||||
.focusRequester(focusRequesters.getValue(index))
|
||||
.focusProperties {
|
||||
up = if (index == 0) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
focusRequesters.getValue(index - 1)
|
||||
}
|
||||
down = if (index == actionIds.lastIndex) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
focusRequesters.getValue(index + 1)
|
||||
}
|
||||
right = FocusRequester.Cancel
|
||||
},
|
||||
onClick = {
|
||||
when (action.kind) {
|
||||
UserQuickActionKind.CLEAR_NOTIFICATIONS ->
|
||||
|
||||
@@ -63,6 +63,9 @@ import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.data.model.UserNotificationAction
|
||||
import com.ponzischeme89.memby.ui.rememberFocusRequesterMap
|
||||
import com.ponzischeme89.memby.ui.requestFocusWithRetries
|
||||
import com.ponzischeme89.memby.ui.formatMyShowDate
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
@@ -114,6 +117,7 @@ fun MyAlertsPage(
|
||||
loading: Boolean = false,
|
||||
errorMessage: String? = null,
|
||||
onRetry: () -> Unit = {},
|
||||
onNotificationAction: (UserNotification, UserNotificationAction) -> Unit = { _, _ -> },
|
||||
onToggleSeen: (UserNotification) -> Unit = {},
|
||||
onDismiss: (UserNotification) -> Unit,
|
||||
onDismissAll: (List<UserNotification>) -> Unit,
|
||||
@@ -132,10 +136,12 @@ fun MyAlertsPage(
|
||||
val safePage = alertPageAfterChange(page, tabNotifications.size)
|
||||
val pageNotifications = alertPageItems(tabNotifications, safePage)
|
||||
val pageIds = pageNotifications.map(UserNotification::id)
|
||||
// Two requesters per row, because a row holds two focus targets and which of them a list
|
||||
// change should land on depends on what the viewer just pressed — see [pendingFocusToggle].
|
||||
val rowFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
|
||||
val toggleFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
|
||||
// Separate requesters per row target, because a row holds the dismiss body plus one or
|
||||
// two side actions and which of them a list change should land on depends on what the
|
||||
// viewer just pressed — see [pendingFocusToggle].
|
||||
val rowFocusRequesters = rememberFocusRequesterMap(pageIds)
|
||||
val actionFocusRequesters = rememberFocusRequesterMap(pageIds)
|
||||
val toggleFocusRequesters = rememberFocusRequesterMap(pageIds)
|
||||
val tabsFocusRequester = remember { FocusRequester() }
|
||||
val listState = rememberLazyListState()
|
||||
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
|
||||
@@ -149,17 +155,15 @@ fun MyAlertsPage(
|
||||
LaunchedEffect(tab) {
|
||||
// One frame for the list to place its first row; an empty pane has nothing below the
|
||||
// strip to land on, so the strip keeps the remote instead.
|
||||
delay(16)
|
||||
runCatching {
|
||||
val first = rowFocusRequesters.firstOrNull()
|
||||
if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus()
|
||||
}
|
||||
requestFocusWithRetries(
|
||||
rowFocusRequesters[pageIds.firstOrNull()],
|
||||
tabsFocusRequester,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(pageIds) {
|
||||
if (!hasAlerts) {
|
||||
pendingFocusIndex = null
|
||||
delay(16)
|
||||
runCatching { tabsFocusRequester.requestFocus() }
|
||||
requestFocusWithRetries(tabsFocusRequester)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
|
||||
@@ -177,11 +181,8 @@ fun MyAlertsPage(
|
||||
alertFocusIndexAfterRemoval(requestedIndex, pageIds.size)
|
||||
} ?: return@LaunchedEffect
|
||||
runCatching { listState.scrollToItem(targetIndex) }
|
||||
delay(16)
|
||||
runCatching {
|
||||
val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters
|
||||
targets.getOrNull(targetIndex)?.requestFocus()
|
||||
}
|
||||
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),
|
||||
|
||||
@@ -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(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -116,7 +115,6 @@ import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -226,15 +224,14 @@ fun MediaQuickActionsOverlay(
|
||||
}
|
||||
val actions = itemActions + rowActions +
|
||||
QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose)
|
||||
val actionCount = actions.size
|
||||
val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } }
|
||||
var focusedIndex by remember(item.id) { mutableIntStateOf(0) }
|
||||
val actionIds = remember(item.id, actions) { actions.indices.toList() }
|
||||
val actionCount = actionIds.size
|
||||
val focusRequesters = rememberFocusRequesterMap(actionIds)
|
||||
// The menu can appear while OK is still physically held. Until that opening press
|
||||
// is released, consume all activation events so it cannot trigger the first action.
|
||||
var openingPressReleased by remember(item.id) { mutableStateOf(false) }
|
||||
LaunchedEffect(item.id) {
|
||||
delay(16.milliseconds)
|
||||
runCatching { focusRequesters.first().requestFocus() }
|
||||
requestFocusWithRetries(focusRequesters[actionIds.firstOrNull()])
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
@@ -267,20 +264,6 @@ fun MediaQuickActionsOverlay(
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
when (event.key) {
|
||||
Key.DirectionUp, Key.DirectionDown -> {
|
||||
val direction = if (event.key == Key.DirectionUp) {
|
||||
QuickActionDirection.UP
|
||||
} else {
|
||||
QuickActionDirection.DOWN
|
||||
}
|
||||
focusedIndex = quickActionNextIndex(
|
||||
currentIndex = focusedIndex,
|
||||
actionCount = actionCount,
|
||||
direction = direction,
|
||||
)
|
||||
runCatching { focusRequesters[focusedIndex].requestFocus() }
|
||||
true
|
||||
}
|
||||
Key.DirectionLeft, Key.Back -> {
|
||||
onClose()
|
||||
true
|
||||
@@ -334,8 +317,20 @@ fun MediaQuickActionsOverlay(
|
||||
label = action.label,
|
||||
icon = action.icon,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
||||
.focusRequester(focusRequesters.getValue(index))
|
||||
.focusProperties {
|
||||
up = if (index == 0) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
focusRequesters.getValue(index - 1)
|
||||
}
|
||||
down = if (index == actionCount - 1) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
focusRequesters.getValue(index + 1)
|
||||
}
|
||||
right = FocusRequester.Cancel
|
||||
},
|
||||
onClick = action.onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
|
||||
@@ -147,13 +146,11 @@ internal fun MediaRow(
|
||||
val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
|
||||
|
||||
rowState.scrollToItem(entryIndex)
|
||||
repeat(6) {
|
||||
delay(16.milliseconds)
|
||||
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
|
||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
requestFocusWithRetries(
|
||||
verticalEntryFocusRequester,
|
||||
attempts = 6,
|
||||
frameDelayMillis = 16.milliseconds.inWholeMilliseconds,
|
||||
)
|
||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||
}
|
||||
|
||||
@@ -317,9 +314,6 @@ internal fun MediaRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun FocusRequester.requestFocusIfAttached(): Boolean =
|
||||
runCatching { requestFocus() }.getOrDefault(false)
|
||||
|
||||
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
|
||||
|
||||
private fun cardFormat(
|
||||
|
||||
@@ -30,7 +30,7 @@ import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
|
||||
/** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */
|
||||
/** Search's compact discovery strip. Unselected chips sit on an OLED-black treatment. */
|
||||
@Composable
|
||||
internal fun SearchGenres(
|
||||
genres: List<String>,
|
||||
@@ -64,7 +64,6 @@ internal fun SearchGenres(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
itemsIndexed(genres.take(6), key = { _, genre -> genre }) { index, genre ->
|
||||
val colour = GenreColours[index % GenreColours.size]
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = { onSelected(genre) },
|
||||
@@ -90,13 +89,19 @@ internal fun SearchGenres(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focused) colour.copy(alpha = 0.98f) else colour)
|
||||
.background(
|
||||
when {
|
||||
focused -> GenreChipFocusedBackground
|
||||
selected -> MembyAccent.copy(alpha = 0.22f)
|
||||
else -> GenreChipBackground
|
||||
},
|
||||
)
|
||||
.border(
|
||||
if (focused || selected) 2.dp else 1.dp,
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> MembyAccent
|
||||
else -> Color.White.copy(alpha = 0.10f)
|
||||
else -> Color.White
|
||||
},
|
||||
GenreShape,
|
||||
)
|
||||
@@ -130,13 +135,5 @@ internal fun SearchGenres(
|
||||
}
|
||||
|
||||
private val GenreShape = RoundedCornerShape(10.dp)
|
||||
|
||||
/** Dark, desaturated counterparts of Search's established genre palette. */
|
||||
private val GenreColours = listOf(
|
||||
Color(0xFF4A332C),
|
||||
Color(0xFF303B50),
|
||||
Color(0xFF3D334B),
|
||||
Color(0xFF263F3B),
|
||||
Color(0xFF4A3A29),
|
||||
Color(0xFF49303A),
|
||||
)
|
||||
private val GenreChipBackground = Color(0xFF050505)
|
||||
private val GenreChipFocusedBackground = Color(0xFF111111)
|
||||
|
||||
@@ -103,6 +103,8 @@ import com.ponzischeme89.memby.data.parseThemeColor
|
||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||
import com.ponzischeme89.memby.ui.TvPreview
|
||||
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
|
||||
import com.ponzischeme89.memby.ui.rememberFocusRequesterMap
|
||||
import com.ponzischeme89.memby.ui.requestFocusWithRetries
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
|
||||
@@ -474,8 +476,11 @@ fun SettingsSheet(
|
||||
var shown by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
shown = true
|
||||
delay(170)
|
||||
runCatching { firstFocus.requestFocus() }
|
||||
requestFocusWithRetries(
|
||||
firstFocus,
|
||||
attempts = 3,
|
||||
frameDelayMillis = 85L,
|
||||
)
|
||||
}
|
||||
|
||||
val state = SettingsPanelState(
|
||||
@@ -782,6 +787,7 @@ internal fun SettingsPanelContent(
|
||||
// page list; this makes Up say the same thing once the pane has no row above.
|
||||
val railSelectionFocusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(state.selectedPage) {
|
||||
contentScrollState.scrollTo(0)
|
||||
@@ -828,16 +834,18 @@ internal fun SettingsPanelContent(
|
||||
// Deliberately not focusProperties { up = … }, which every row in the
|
||||
// pane inherits and which would take a page's own vertical navigation
|
||||
// away from it; and not `exit`, which is never consulted when the search
|
||||
// finds nothing anywhere, and finding nothing is the whole case. So the
|
||||
// move the default handler would have made is made here first, and the
|
||||
// rail is reached only when it fails — which is the top control and
|
||||
// nothing else.
|
||||
.onKeyEvent { event ->
|
||||
// finds nothing anywhere, and finding nothing is the whole case. The edge
|
||||
// has to be owned in preview dispatch because by the time a child bubbles a
|
||||
// failed Up here, Compose has already given up on moving focus.
|
||||
.onPreviewKeyEvent { event ->
|
||||
when {
|
||||
event.type != KeyEventType.KeyDown -> false
|
||||
event.key != Key.DirectionUp -> false
|
||||
focusManager.moveFocus(FocusDirection.Up) -> true
|
||||
else -> runCatching { railSelectionFocusRequester.requestFocus() }.isSuccess
|
||||
else -> {
|
||||
scope.launch { requestFocusWithRetries(railSelectionFocusRequester) }
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
.verticalScroll(contentScrollState)
|
||||
@@ -849,6 +857,7 @@ internal fun SettingsPanelContent(
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
|
||||
) {
|
||||
val pageEntryModifier = Modifier.focusProperties { up = railSelectionFocusRequester }
|
||||
SettingsHeader(page = state.selectedPage)
|
||||
when (state.selectedPage) {
|
||||
SettingsPage.APPEARANCE -> SettingsGroup {
|
||||
@@ -869,6 +878,7 @@ internal fun SettingsPanelContent(
|
||||
locked = state.themeLocked,
|
||||
notice = state.themeNotice,
|
||||
onSelected = actions.onThemeChanged,
|
||||
modifier = pageEntryModifier,
|
||||
)
|
||||
SettingDivider()
|
||||
}
|
||||
@@ -877,6 +887,11 @@ internal fun SettingsPanelContent(
|
||||
description = "Use a film or show's own logo instead of plain text.",
|
||||
checked = state.showLogo,
|
||||
onCheckedChange = actions.onShowLogoChanged,
|
||||
modifier = if (THEME_PICKER_ENABLED && state.themeOptions.size > 1) {
|
||||
Modifier
|
||||
} else {
|
||||
pageEntryModifier
|
||||
},
|
||||
)
|
||||
SettingDivider()
|
||||
SettingsChoiceRow(
|
||||
@@ -916,6 +931,7 @@ internal fun SettingsPanelContent(
|
||||
onSelected = { value ->
|
||||
actions.onAudioPassthroughModeChanged(AudioPassthroughMode.from(value))
|
||||
},
|
||||
modifier = pageEntryModifier,
|
||||
)
|
||||
if (state.audioPassthroughMode == AudioPassthroughMode.MANUAL) {
|
||||
SurroundCodec.entries.forEach { codec ->
|
||||
@@ -995,6 +1011,7 @@ internal fun SettingsPanelContent(
|
||||
description = copy.second,
|
||||
checked = key in state.homeSections,
|
||||
onCheckedChange = { actions.onHomeSectionChanged(key, it) },
|
||||
modifier = if (index == 0) pageEntryModifier else Modifier,
|
||||
)
|
||||
}
|
||||
SettingDivider()
|
||||
@@ -1066,6 +1083,7 @@ internal fun SettingsPanelContent(
|
||||
onRename = { actions.onRenameDevice(device) },
|
||||
onRemove = { actions.onRemoveDevice(device) },
|
||||
onCancelRemove = actions.onCancelDeviceRemoval,
|
||||
modifier = if (index == 0) pageEntryModifier else Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1075,6 +1093,7 @@ internal fun SettingsPanelContent(
|
||||
description = "Check which TVs are signed in right now.",
|
||||
badge = "REFRESH",
|
||||
onClick = actions.onRefreshDevices,
|
||||
modifier = if (state.devices.isEmpty()) pageEntryModifier else Modifier,
|
||||
)
|
||||
}
|
||||
SettingsPage.STORAGE -> SettingsGroup {
|
||||
@@ -1099,6 +1118,7 @@ internal fun SettingsPanelContent(
|
||||
else -> formatCacheSize(cache.totalBytes).uppercase()
|
||||
},
|
||||
onClick = actions.onClearImageCache,
|
||||
modifier = pageEntryModifier,
|
||||
)
|
||||
// Only the confirmation gets a tinted band. A standing explanation in
|
||||
// the notice colour would be the loudest thing on a page whose whole
|
||||
@@ -1160,9 +1180,10 @@ private fun DeviceManagementRow(
|
||||
onRename: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onCancelRemove: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -1242,13 +1263,15 @@ private fun DeviceRenameDialog(
|
||||
// lets Back close the whole Settings destination behind the still-running rename.
|
||||
BackHandler { if (!busy) onCancel() }
|
||||
LaunchedEffect(device.deviceId) {
|
||||
delay(80L)
|
||||
runCatching { fieldFocus.requestFocus() }
|
||||
requestFocusWithRetries(
|
||||
fieldFocus,
|
||||
attempts = 3,
|
||||
frameDelayMillis = 40L,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(busy) {
|
||||
if (busy) {
|
||||
delay(16L)
|
||||
runCatching { cancelFocus.requestFocus() }
|
||||
requestFocusWithRetries(cancelFocus)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -1360,12 +1383,18 @@ private fun SettingsSecondaryRail(
|
||||
// bounds happen to be closer (notably below Playback on a 540p viewport). Give
|
||||
// every vertical move an explicit destination so focus cannot leak through the
|
||||
// settings surface and activate home content.
|
||||
val railFocusRequesters = remember(firstFocusRequester) {
|
||||
val retainedRailFocusRequesters = rememberFocusRequesterMap(pages.map(SettingsPage::name))
|
||||
val railFocusRequesters = remember(
|
||||
pages,
|
||||
selectedRailPage,
|
||||
firstFocusRequester,
|
||||
retainedRailFocusRequesters,
|
||||
) {
|
||||
pages.map { page ->
|
||||
if (page == selectedRailPage && firstFocusRequester != null) {
|
||||
firstFocusRequester
|
||||
} else {
|
||||
FocusRequester()
|
||||
retainedRailFocusRequesters.getValue(page.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1464,8 +1493,12 @@ private fun SettingsSecondaryRail(
|
||||
// first, then hand focus to it once it has composed.
|
||||
onSelected(page)
|
||||
scope.launch {
|
||||
delay(32L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
requestFocusWithRetries(
|
||||
contentFocusRequester,
|
||||
attempts = 3,
|
||||
frameDelayMillis = 16L,
|
||||
initialDelayMillis = 16L,
|
||||
)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1685,9 +1718,10 @@ private fun SettingsColourSchemeRow(
|
||||
locked: Boolean,
|
||||
notice: String,
|
||||
onSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
@@ -1738,9 +1772,10 @@ private fun SettingsChoiceRow(
|
||||
options: List<ChoiceOption>,
|
||||
selected: String,
|
||||
onSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
modifier = modifier.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ponzischeme89.memby.ui.alerts
|
||||
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.data.model.UserNotificationAction
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
@@ -80,6 +81,17 @@ class AlertsFormatTest {
|
||||
assertEquals(listOf(9L, 4L, 6L), alertsForTab(AlertsTab.INBOX, notifications).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification action keeps its completion label for the immediate UI update`() {
|
||||
val action = UserNotificationAction(
|
||||
kind = "remove-my-show",
|
||||
label = "Remove from My Shows",
|
||||
completedLabel = "Removed from My Shows",
|
||||
)
|
||||
assertEquals("Remove from My Shows", action.label)
|
||||
assertEquals("Removed from My Shows", action.completedLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tab count is drawn as itself, zero included`() {
|
||||
assertEquals("0", alertTabCountLabel(0))
|
||||
|
||||
@@ -142,6 +142,27 @@ class AlertsPageScreenshotTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled show with remove action`() {
|
||||
capture(
|
||||
"my-alerts-cancelled-remove-action",
|
||||
listOf(
|
||||
UserNotification(
|
||||
id = 1,
|
||||
kind = "show-cancelled",
|
||||
itemId = "emby-1",
|
||||
title = "The Peripheral - Cancelled",
|
||||
message = "The Peripheral has been cancelled by the network.",
|
||||
action = com.ponzischeme89.memby.data.model.UserNotificationAction(
|
||||
kind = "remove-my-show",
|
||||
label = "Remove from My Shows",
|
||||
completedLabel = "Removed from My Shows",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The way in: the user menu in the user picker, with the badge that replaced the bell
|
||||
* on the launcher. Captured here rather than beside the profile switcher's own tests
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user