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