0.3.05
This commit is contained in:
@@ -470,7 +470,8 @@ still hear the news. Four publishers today:
|
||||
repeating an outage every minute would bury everything else. This is the alert that
|
||||
earns the banner its place over playback: video direct-plays from Emby, so when Emby
|
||||
stops answering the film stalls with no explanation, and the gateway is still up to
|
||||
say why. `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe and both banners off.
|
||||
say why when the household permits playback overlays. `MEMBY_EMBY_HEALTH_INTERVAL=0`
|
||||
turns the probe and both banners off.
|
||||
|
||||
`mergeAlerts` interleaves derived and published alerts newest-first and caps at
|
||||
`maxAlerts`, which is why every alert carries a timestamp. Alerts also carry their own
|
||||
@@ -492,13 +493,22 @@ because stealing D-pad focus mid-browse is worse than a missed notice;
|
||||
and alerts are suppressed under maintenance and under a mandatory update, which own the
|
||||
screen.
|
||||
|
||||
**The bar appears over playback too**, not only over the launcher: `PlayerActivity`
|
||||
mounts the same composable in a `ComposeView` (`player_service_alerts` in
|
||||
`activity_player.xml`, declared before the loading and error overlays so those cover it).
|
||||
**Where the bar may appear is the server's decision.** `GatewaySettings.NotificationDisplay`
|
||||
is `everywhere`, `home_only` (the default) or `off`, and `/v1/status` carries the effective
|
||||
value on every poll. `NotificationDisplayPolicy.fromWire` defaults missing and unknown
|
||||
values to Home only, and `notificationEligible` is the single rule every renderer uses:
|
||||
Home only admits browsing and refuses playback, Everywhere admits both, and Off admits
|
||||
neither. The server also omits `alerts` under Off, but the client still applies the rule so
|
||||
an alert already offered cannot remain visible after an operator changes the setting.
|
||||
Only the call sites identify their `NotificationSurface`; no screen owns policy logic.
|
||||
|
||||
When Everywhere is selected, `PlayerActivity` mounts the same composable in a `ComposeView`
|
||||
(`player_service_alerts` in `activity_player.xml`, declared before the loading and error
|
||||
overlays so those cover it).
|
||||
`alertsSuppressed` starts *true* and is cleared only by `hidePlaybackLoading`, which is
|
||||
reached once the preroll is over and the first frame is up — an alert composed behind an
|
||||
overlay would be marked seen by a viewer who never saw it, which is the same failure
|
||||
`alertShown` exists to prevent. Because it now covers somebody's film, the bar is
|
||||
`alertShown` exists to prevent. Because it can cover somebody's film, the bar is
|
||||
deliberately small (76dp), near-black, and eases in over ~680ms rather than snapping
|
||||
down. It shows the **Emby mark**, not item artwork: a library refresh and an outage have
|
||||
no artwork, and one constant mark reads as "your server is talking" where a poster made
|
||||
|
||||
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-BQVB9Fvj.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-C6xrkGsh.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
|
||||
</head>
|
||||
|
||||
@@ -757,15 +757,16 @@ export interface GoroutineReport {
|
||||
|
||||
/* ---------- gateway settings ---------- */
|
||||
|
||||
/** GatewaySettings is the operator's overrides. Every field is optional in meaning: an
|
||||
* empty string or a zero means "whatever the container was started with", and -1 means
|
||||
* off for the three that can be switched off. */
|
||||
/** GatewaySettings is the operator's server document. Environment-backed fields use an
|
||||
* empty string or zero for "whatever the container was started with"; notification
|
||||
* display is native to this document and defaults to home only. */
|
||||
export interface GatewaySettings {
|
||||
timezone: string;
|
||||
logLevel: string;
|
||||
sessionIdleDays: number;
|
||||
sonarrAlertMinutes: number;
|
||||
radarrAlertMinutes: number;
|
||||
notificationDisplay: string;
|
||||
embyHealthSeconds: number;
|
||||
slowRequestMillis: number;
|
||||
librarySyncMinutes: number;
|
||||
@@ -781,6 +782,7 @@ export interface GatewaySettingValues {
|
||||
sessionIdleDays: number;
|
||||
sonarrAlertMinutes: number;
|
||||
radarrAlertMinutes: number;
|
||||
notificationDisplay: string;
|
||||
embyHealthSeconds: number;
|
||||
slowRequestMillis: number;
|
||||
librarySyncMinutes: number;
|
||||
@@ -791,6 +793,7 @@ export interface GatewaySettingsResponse {
|
||||
deployed: GatewaySettingValues;
|
||||
effective: GatewaySettingValues;
|
||||
logLevels: string[] | null;
|
||||
notificationDisplays: string[] | null;
|
||||
version: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,9 @@ import { Banner, Button, Card, Field, KeyValue, Loading, Note, PageHead, Tag } f
|
||||
* has not been switched on. That is why it is reached from the account menu rather than
|
||||
* from the rail: it belongs beside "signed in as", not beside the household's content.
|
||||
*
|
||||
* The whole page is an amendment to `.env`. Every field is blank by default and blank
|
||||
* means "whatever this container was started with", which is printed beside it — so an
|
||||
* operator can always see what they are overriding, and clearing a field is a real undo
|
||||
* rather than a value they have to remember. */
|
||||
* Most fields amend `.env`: blank means "whatever this container was started with", which
|
||||
* is printed beside the field. Notification display is server-native instead and has a
|
||||
* Home-only default. */
|
||||
|
||||
/** OVERRIDE_OFF is the wire value for "switched off", which the three window settings need
|
||||
* to distinguish from an empty field. See store.GatewaySettingsOff. */
|
||||
@@ -46,12 +45,21 @@ function describe(value: number, unit: string): string {
|
||||
return `${value} ${unit}${value === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function notificationDisplayLabel(value: string): string {
|
||||
switch (value) {
|
||||
case 'everywhere': return 'Everywhere';
|
||||
case 'off': return 'Off';
|
||||
default: return 'Home only';
|
||||
}
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
timezone: string;
|
||||
logLevel: string;
|
||||
sessionIdleDays: string;
|
||||
sonarrAlertMinutes: string;
|
||||
radarrAlertMinutes: string;
|
||||
notificationDisplay: string;
|
||||
embyHealthSeconds: string;
|
||||
slowRequestMillis: string;
|
||||
librarySyncMinutes: string;
|
||||
@@ -64,6 +72,7 @@ function draftFrom(settings: GatewaySettings): Draft {
|
||||
sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
|
||||
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
|
||||
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
|
||||
notificationDisplay: settings.notificationDisplay || 'home_only',
|
||||
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
|
||||
slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
|
||||
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
|
||||
@@ -95,6 +104,7 @@ export function SettingsPage() {
|
||||
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
|
||||
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
|
||||
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
|
||||
notificationDisplay: draft.notificationDisplay,
|
||||
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
|
||||
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
|
||||
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
|
||||
@@ -117,9 +127,9 @@ export function SettingsPage() {
|
||||
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
|
||||
timezone: '', logLevel: '', sessionIdleDays: 0,
|
||||
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0,
|
||||
librarySyncMinutes: 0,
|
||||
librarySyncMinutes: 0, notificationDisplay: 'home_only',
|
||||
}),
|
||||
'Every setting is back to what this container was deployed with.',
|
||||
'Every setting is back to its deployed or server default.',
|
||||
);
|
||||
if (saved) setDraft(draftFrom(saved.settings));
|
||||
await reload();
|
||||
@@ -128,6 +138,7 @@ export function SettingsPage() {
|
||||
const deployed = data?.deployed;
|
||||
const effective = data?.effective;
|
||||
const levels = data?.logLevels ?? [];
|
||||
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -158,13 +169,14 @@ export function SettingsPage() {
|
||||
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
|
||||
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
|
||||
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
|
||||
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Overrides"
|
||||
intro="Leave a field empty to use the value this container was deployed with, shown beneath it. Changes take effect immediately — nothing here needs a restart."
|
||||
title="Server settings"
|
||||
intro="Environment-backed fields can be left empty to use the deployed value shown beneath them. Changes take effect immediately — nothing here needs a restart."
|
||||
icon="sliders"
|
||||
tone="note"
|
||||
footer={
|
||||
@@ -173,7 +185,7 @@ export function SettingsPage() {
|
||||
Save settings
|
||||
</Button>
|
||||
<Button busy={busy === 'clear'} onClick={() => void clearAll()}>
|
||||
Use deployed values
|
||||
Use defaults
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -262,6 +274,22 @@ export function SettingsPage() {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="fields">
|
||||
<Field
|
||||
label="Notification display"
|
||||
hint="Controls informational banners for every viewer and television. Everywhere also permits playback overlays; Home only keeps them off active movies, episodes and trailers; Off hides them throughout Memby."
|
||||
>
|
||||
<select
|
||||
value={draft.notificationDisplay}
|
||||
onChange={(event) => set('notificationDisplay', event.target.value)}
|
||||
>
|
||||
{notificationDisplays.map((value) => (
|
||||
<option key={value} value={value}>{notificationDisplayLabel(value)}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="fields">
|
||||
<Field
|
||||
label="Episode alert window (minutes)"
|
||||
@@ -291,10 +319,9 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<Note tone="note">
|
||||
These override the deployed configuration in the database, so they survive a
|
||||
restart — but a deployment rewrites <code>.env</code>, not this, and the two can
|
||||
then disagree. Anything meant to be permanent belongs in <code>.env.example</code>{' '}
|
||||
as well.
|
||||
These settings live in the database and survive a restart. A deployment rewrites
|
||||
<code>.env</code>, not this document, so environment-backed values can then disagree;
|
||||
permanent environment changes belong in <code>.env.example</code> as well.
|
||||
</Note>
|
||||
{data?.settings.updatedBy ? (
|
||||
<Note>
|
||||
|
||||
@@ -62,7 +62,7 @@ val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
val defaultVersionName = "0.3.04"
|
||||
val defaultVersionName = "0.3.05"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -39,7 +39,7 @@ data class EmbyOutage(
|
||||
/**
|
||||
* One informational banner: a show aired, a film was added, the library finished
|
||||
* refreshing, the server stopped answering. It is never actionable and never focusable —
|
||||
* it slides in, says its piece and goes, over the launcher or over playback alike.
|
||||
* it slides in, says its piece and goes wherever the server's display policy permits.
|
||||
*
|
||||
* [label] is the eyebrow above the title and comes from the server, so a kind of news
|
||||
* this build has never heard of still reads correctly; a server that sends none gets the
|
||||
@@ -83,6 +83,9 @@ class MaintenanceMonitor(
|
||||
private val _alert = MutableStateFlow<ServiceAlert?>(null)
|
||||
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
|
||||
|
||||
private val _notificationDisplay = MutableStateFlow(NotificationDisplayPolicy.HOME_ONLY)
|
||||
val notificationDisplay: StateFlow<NotificationDisplayPolicy> = _notificationDisplay.asStateFlow()
|
||||
|
||||
private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)
|
||||
|
||||
/** Null whenever Emby is answering, or when nothing is watching it. */
|
||||
@@ -296,6 +299,7 @@ class MaintenanceMonitor(
|
||||
_viewersEnabled.value = false
|
||||
setRequestsAllowed(false)
|
||||
_gatewayVersion.value = ""
|
||||
_notificationDisplay.value = NotificationDisplayPolicy.HOME_ONLY
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
}
|
||||
@@ -344,6 +348,8 @@ class MaintenanceMonitor(
|
||||
_viewersEnabled.value = status.features[VIEWERS_FEATURE] == true
|
||||
setRequestsAllowed(status.requests.allowed)
|
||||
_gatewayVersion.value = status.gatewayVersion
|
||||
_notificationDisplay.value =
|
||||
NotificationDisplayPolicy.fromWire(status.notificationDisplay)
|
||||
// Emby's state is reported even during maintenance: an
|
||||
// operator taking Memby down while Emby is also unreachable
|
||||
// should not have that fact disappear from the poll.
|
||||
@@ -387,6 +393,7 @@ class MaintenanceMonitor(
|
||||
_viewersEnabled.value = false
|
||||
setRequestsAllowed(false)
|
||||
_gatewayVersion.value = ""
|
||||
_notificationDisplay.value = NotificationDisplayPolicy.HOME_ONLY
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
/** Household-wide notification placement chosen by the server administrator. */
|
||||
enum class NotificationDisplayPolicy(val wireValue: String) {
|
||||
EVERYWHERE("everywhere"),
|
||||
HOME_ONLY("home_only"),
|
||||
OFF("off"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Missing and unknown values fail safe: browsing is allowed, playback is not. */
|
||||
fun fromWire(value: String?): NotificationDisplayPolicy =
|
||||
entries.firstOrNull { it.wireValue == value?.trim()?.lowercase() } ?: HOME_ONLY
|
||||
}
|
||||
}
|
||||
|
||||
/** The two display contexts that matter to the global interruption policy. */
|
||||
enum class NotificationSurface {
|
||||
BROWSING,
|
||||
PLAYBACK,
|
||||
}
|
||||
|
||||
/**
|
||||
* The single eligibility rule for server notifications.
|
||||
*
|
||||
* Callers identify their surface; this function owns what each server policy means. New
|
||||
* notification renderers should use this rather than interpreting the wire value or
|
||||
* making their own playback decision.
|
||||
*/
|
||||
fun notificationEligible(
|
||||
policy: NotificationDisplayPolicy,
|
||||
surface: NotificationSurface,
|
||||
): Boolean = when (policy) {
|
||||
NotificationDisplayPolicy.EVERYWHERE -> true
|
||||
NotificationDisplayPolicy.HOME_ONLY -> surface == NotificationSurface.BROWSING
|
||||
NotificationDisplayPolicy.OFF -> false
|
||||
}
|
||||
@@ -213,6 +213,8 @@ data class GatewayServiceStatus(
|
||||
val maintenance: Boolean = false,
|
||||
val message: String = "",
|
||||
val alerts: List<GatewayAlert> = emptyList(),
|
||||
/** Server-owned placement policy. Missing or unreadable means home only. */
|
||||
val notificationDisplay: String = "home_only",
|
||||
val compatible: Boolean = true,
|
||||
val compatibilityMessage: String = "",
|
||||
val clientVersion: String = "",
|
||||
|
||||
@@ -2,10 +2,10 @@ package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -28,12 +28,17 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CornerRadius
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Offset
|
||||
import androidx.compose.ui.graphics.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -62,6 +67,8 @@ import kotlinx.coroutines.delay
|
||||
|
||||
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
|
||||
|
||||
private val HeroFocusRingWidth = 2.dp
|
||||
|
||||
/**
|
||||
* The current local day, re-read when the clock passes midnight.
|
||||
*
|
||||
@@ -385,17 +392,11 @@ private fun FeaturedMovieCard(
|
||||
// Not "Featured movie": a series premiere can lead, and a screen reader announcing
|
||||
// one as a movie is worse than one announcing it by the caption it is wearing.
|
||||
contentDescription = "Featured, ${pick.label}, ${item.name}",
|
||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||
modifier = modifier,
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MembySurfaceRaised)
|
||||
.border(
|
||||
2.dp,
|
||||
if (focused) Color.White else Color.Transparent,
|
||||
RoundedCornerShape(MembyPanelCorner),
|
||||
),
|
||||
HeroFocusFrame(
|
||||
focused = focused,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||
Box(
|
||||
@@ -547,17 +548,11 @@ private fun MiniMovieCard(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "${pick.label} movie, ${item.name}",
|
||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||
modifier = modifier,
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MembySurfaceRaised)
|
||||
.border(
|
||||
2.dp,
|
||||
if (focused) Color.White else Color.Transparent,
|
||||
RoundedCornerShape(MembyPanelCorner),
|
||||
),
|
||||
HeroFocusFrame(
|
||||
focused = focused,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
||||
@@ -604,6 +599,47 @@ private fun MiniMovieCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The common artwork boundary for both hero sizes.
|
||||
*
|
||||
* The artwork is clipped to the hero shape, while the focus stroke is drawn immediately
|
||||
* outside that boundary. Keeping those as separate modifier layers means the ring neither
|
||||
* clips itself nor consumes any of the artwork, and its physical width stays the same for
|
||||
* the featured card and every mini card.
|
||||
*/
|
||||
@Composable
|
||||
private fun HeroFocusFrame(
|
||||
focused: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(MembyPanelCorner)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.drawWithCache {
|
||||
val strokeWidth = HeroFocusRingWidth.toPx()
|
||||
val halfStroke = strokeWidth / 2f
|
||||
val ringBounds = Size(size.width + strokeWidth, size.height + strokeWidth)
|
||||
val ringCorner = CornerRadius(MembyPanelCorner.toPx() + halfStroke)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
if (focused) {
|
||||
drawRoundRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(-halfStroke, -halfStroke),
|
||||
size = ringBounds,
|
||||
cornerRadius = ringCorner,
|
||||
style = Stroke(strokeWidth),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clip(shape)
|
||||
.background(MembySurfaceRaised),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
|
||||
if (previewArtwork != null) {
|
||||
|
||||
@@ -68,6 +68,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.NotificationSurface
|
||||
import com.ponzischeme89.memby.data.StartupPosterSnapshotCache
|
||||
import com.ponzischeme89.memby.data.StartupPosterSource
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
@@ -2765,6 +2766,7 @@ internal fun HomeScreen(
|
||||
// News about the library, not about the app: it sits above the rows but is
|
||||
// suppressed whenever something more important owns the screen.
|
||||
ServiceAlertBanner(
|
||||
surface = NotificationSurface.BROWSING,
|
||||
// The alert itself is collected inside the banner, so an arriving one does
|
||||
// not recompose this whole function. Only the suppression conditions — both
|
||||
// already read here for other reasons — cross the boundary.
|
||||
|
||||
@@ -50,7 +50,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||
import com.ponzischeme89.memby.data.NotificationSurface
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
import com.ponzischeme89.memby.data.notificationEligible
|
||||
import kotlin.math.ceil
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
|
||||
@@ -61,8 +63,8 @@ private val AlertBody = Color(0xFFC3CBD2)
|
||||
/**
|
||||
* Height of the bar itself, before the rule and fade that blend it into the screen.
|
||||
*
|
||||
* Kept to a strip rather than a panel because this now appears over playback as well as
|
||||
* over the launcher: whatever it says, it is covering somebody's film while it says it.
|
||||
* Kept to a strip rather than a panel because the server may permit it over playback as
|
||||
* well as over the launcher.
|
||||
*/
|
||||
private val BannerHeight = 48.dp
|
||||
|
||||
@@ -83,12 +85,19 @@ private val SafeAreaHorizontal = 48.dp
|
||||
* allowed to own the whole display.
|
||||
*/
|
||||
@Composable
|
||||
fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
|
||||
fun ServiceAlertBanner(
|
||||
surface: NotificationSurface,
|
||||
suppressed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// The alert is collected *here* rather than passed down from the home composable.
|
||||
// Read one scope up and every arriving alert would recompose the entire launcher
|
||||
// body; read here and it recomposes a bar that is usually not even on screen.
|
||||
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle()
|
||||
val alert = current?.takeUnless { suppressed }
|
||||
val policy by ServiceLocator.maintenance.notificationDisplay.collectAsStateWithLifecycle()
|
||||
val alert = current?.takeIf {
|
||||
!suppressed && notificationEligible(policy, surface)
|
||||
}
|
||||
|
||||
// Tell the monitor the moment this is really on screen. Until it hears that, the
|
||||
// alert is only offered — it starts no dismissal timer and persists nothing — so an
|
||||
|
||||
@@ -131,6 +131,7 @@ fun LandscapeCard(
|
||||
modifier: Modifier = Modifier,
|
||||
density: String = "standard",
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
showFavouriteIndicator: Boolean = true,
|
||||
) {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 5
|
||||
@@ -138,12 +139,13 @@ fun LandscapeCard(
|
||||
else -> 4
|
||||
}
|
||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
|
||||
val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount) {
|
||||
val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount, showFavouriteIndicator) {
|
||||
item.toMediaCardUiModel(
|
||||
showProgress = false,
|
||||
showSecondaryMetadata,
|
||||
showWatchedEpisodeCount,
|
||||
showMediaTypeIcon = false,
|
||||
showFavouriteIndicator = showFavouriteIndicator,
|
||||
)
|
||||
}
|
||||
MediaCard(
|
||||
|
||||
@@ -107,12 +107,15 @@ internal data class MediaCardUiModel(
|
||||
/**
|
||||
* @param showProgress whether a "Resume at …" subtitle should replace the default one when
|
||||
* the item has a saved position — a row-level choice, not a fact about the item.
|
||||
* @param showFavouriteIndicator whether favourite state adds a heart to the card. Favourites
|
||||
* rows turn this off because the surrounding row already communicates that state.
|
||||
*/
|
||||
internal fun BaseItem.toMediaCardUiModel(
|
||||
showProgress: Boolean,
|
||||
showSecondaryMetadata: Boolean,
|
||||
showWatchedEpisodeCount: Boolean,
|
||||
showMediaTypeIcon: Boolean,
|
||||
showFavouriteIndicator: Boolean,
|
||||
): MediaCardUiModel {
|
||||
val position = userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = runTimeTicks ?: 0L
|
||||
@@ -128,7 +131,7 @@ internal fun BaseItem.toMediaCardUiModel(
|
||||
showSecondaryMetadata = showSecondaryMetadata,
|
||||
contentDescription = cardDescription(this, progress, showWatchedEpisodeCount, showMediaTypeIcon),
|
||||
played = userData?.played == true,
|
||||
favourite = isFavorite,
|
||||
favourite = isFavorite && showFavouriteIndicator,
|
||||
progress = progress,
|
||||
isSchedule = isSchedule,
|
||||
scheduleAvailability = membyAvailability.orEmpty(),
|
||||
@@ -152,6 +155,7 @@ fun PosterCard(
|
||||
density: String = "standard",
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
showProgress: Boolean = false,
|
||||
showFavouriteIndicator: Boolean = true,
|
||||
) {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 8
|
||||
@@ -159,8 +163,20 @@ fun PosterCard(
|
||||
else -> 7
|
||||
}
|
||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
val model = remember(item, showProgress, showSecondaryMetadata, showWatchedEpisodeCount) {
|
||||
item.toMediaCardUiModel(showProgress, showSecondaryMetadata, showWatchedEpisodeCount, showMediaTypeIcon = false)
|
||||
val model = remember(
|
||||
item,
|
||||
showProgress,
|
||||
showSecondaryMetadata,
|
||||
showWatchedEpisodeCount,
|
||||
showFavouriteIndicator,
|
||||
) {
|
||||
item.toMediaCardUiModel(
|
||||
showProgress,
|
||||
showSecondaryMetadata,
|
||||
showWatchedEpisodeCount,
|
||||
showMediaTypeIcon = false,
|
||||
showFavouriteIndicator = showFavouriteIndicator,
|
||||
)
|
||||
}
|
||||
MediaCard(
|
||||
model, item, width, 2f / 3f, preferPrimary = true, showProgress,
|
||||
@@ -192,6 +208,7 @@ fun PosterGridCard(
|
||||
showSecondaryMetadata = true,
|
||||
showWatchedEpisodeCount,
|
||||
showMediaTypeIcon,
|
||||
showFavouriteIndicator = true,
|
||||
)
|
||||
}
|
||||
MediaCard(
|
||||
|
||||
@@ -319,12 +319,14 @@ internal fun MediaRow(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||
)
|
||||
MediaCardFormat.LANDSCAPE -> {
|
||||
LandscapeCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.ponzischeme89.memby.data.analytics.PlaybackEntryPoint
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||
import com.ponzischeme89.memby.data.creditsWorthShowing
|
||||
import com.ponzischeme89.memby.data.NextEpisode
|
||||
import com.ponzischeme89.memby.data.NotificationSurface
|
||||
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
|
||||
import com.ponzischeme89.memby.data.Playable
|
||||
import com.ponzischeme89.memby.data.PlayableSubtitle
|
||||
@@ -4647,12 +4648,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the launcher's service-alert bar over the video.
|
||||
* Hosts the launcher's service-alert bar over the video when the server permits it.
|
||||
*
|
||||
* News about the server is worth more here than anywhere else — playback direct-plays
|
||||
* from Emby, so "the server has stopped communicating" explains a stall the viewer is
|
||||
* looking at right now. The bar is never focusable, so the transport controls keep the
|
||||
* remote; it times itself out rather than asking for a press.
|
||||
* Under Everywhere, news about the server can explain a stall the viewer is looking at
|
||||
* right now. The bar is never focusable, so the transport controls keep the remote; it
|
||||
* times itself out rather than asking for a press. Home only and Off remain eligible
|
||||
* nowhere in this activity through the shared notification rule.
|
||||
*
|
||||
* [alertsSuppressed] is what stops an alert being *used up* behind the loading or
|
||||
* error overlay: the banner records an alert as seen the moment it composes, and the
|
||||
@@ -4675,6 +4676,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
.collectAsStateWithLifecycle()
|
||||
EmbyOutageBanner(suppressed = alertsSuppressed.value)
|
||||
ServiceAlertBanner(
|
||||
surface = NotificationSurface.PLAYBACK,
|
||||
suppressed = alertsSuppressed.value || outage != null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,30 @@ class ServiceAlertTest {
|
||||
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
|
||||
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
|
||||
|
||||
@Test
|
||||
fun `home only allows browsing notifications but not playback overlays`() {
|
||||
assertTrue(notificationEligible(NotificationDisplayPolicy.HOME_ONLY, NotificationSurface.BROWSING))
|
||||
assertFalse(notificationEligible(NotificationDisplayPolicy.HOME_ONLY, NotificationSurface.PLAYBACK))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everywhere allows both notification surfaces`() {
|
||||
assertTrue(notificationEligible(NotificationDisplayPolicy.EVERYWHERE, NotificationSurface.BROWSING))
|
||||
assertTrue(notificationEligible(NotificationDisplayPolicy.EVERYWHERE, NotificationSurface.PLAYBACK))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `off prevents notifications on every surface`() {
|
||||
assertFalse(notificationEligible(NotificationDisplayPolicy.OFF, NotificationSurface.BROWSING))
|
||||
assertFalse(notificationEligible(NotificationDisplayPolicy.OFF, NotificationSurface.PLAYBACK))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing and unknown server policy fail safe to home only`() {
|
||||
assertEquals(NotificationDisplayPolicy.HOME_ONLY, NotificationDisplayPolicy.fromWire(null))
|
||||
assertEquals(NotificationDisplayPolicy.HOME_ONLY, NotificationDisplayPolicy.fromWire("unexpected"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `picks the first alert this tv has not seen`() {
|
||||
val chosen = firstUnseenAlert(
|
||||
@@ -68,6 +92,7 @@ class ServiceAlertTest {
|
||||
"""{"maintenance":false,"message":""}""",
|
||||
)
|
||||
assertTrue(status.alerts.isEmpty())
|
||||
assertEquals("home_only", status.notificationDisplay)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,6 +114,18 @@ class ServiceAlertTest {
|
||||
assertEquals("", alert.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes the server notification display policy`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"notificationDisplay":"everywhere"}""",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
NotificationDisplayPolicy.EVERYWHERE,
|
||||
NotificationDisplayPolicy.fromWire(status.notificationDisplay),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes a radarr import alert with its own wording`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
|
||||
@@ -22,11 +22,17 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.requestFocus
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
@@ -48,6 +54,7 @@ import org.robolectric.annotation.GraphicsMode
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
|
||||
class HomeMovieHeroScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
@@ -62,6 +69,25 @@ class HomeMovieHeroScreenshotTest {
|
||||
capture("df_home-movie-hero", movies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hero focus treatment matches featured and mini cards`() {
|
||||
setHeroContent(movies)
|
||||
|
||||
compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon")
|
||||
.requestFocus()
|
||||
.assertIsFocused()
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/home-movie-hero/featured-focused.png",
|
||||
)
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
compose.onNodeWithContentDescription("POPULAR movie, Midnight Signal")
|
||||
.assertIsFocused()
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/home-movie-hero/mini-focused.png",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the audit reproduced: with a title long enough to wrap, the card's content
|
||||
* column used to overflow its fixed height around the individual Play chip. The hero
|
||||
@@ -287,6 +313,11 @@ class HomeMovieHeroScreenshotTest {
|
||||
}
|
||||
|
||||
private fun capture(name: String, movies: List<HomeHeroPick>) {
|
||||
setHeroContent(movies)
|
||||
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
|
||||
}
|
||||
|
||||
private fun setHeroContent(movies: List<HomeHeroPick>) {
|
||||
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
|
||||
.use(BitmapFactory::decodeStream)
|
||||
.asImageBitmap()
|
||||
@@ -364,7 +395,6 @@ class HomeMovieHeroScreenshotTest {
|
||||
}
|
||||
|
||||
assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty())
|
||||
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
|
||||
}
|
||||
|
||||
private val movies = listOf(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MediaCardUiModelTest {
|
||||
private val favourite = BaseItem(
|
||||
id = "favourite",
|
||||
userData = UserItemData(isFavorite = true),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `favourite indicator remains visible on ordinary browsing cards`() {
|
||||
assertTrue(favourite.cardModel(showFavouriteIndicator = true).favourite)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `favourite indicator can be hidden when the browsing context is favourites`() {
|
||||
assertFalse(favourite.cardModel(showFavouriteIndicator = false).favourite)
|
||||
}
|
||||
|
||||
private fun BaseItem.cardModel(showFavouriteIndicator: Boolean): MediaCardUiModel =
|
||||
toMediaCardUiModel(
|
||||
showProgress = false,
|
||||
showSecondaryMetadata = true,
|
||||
showWatchedEpisodeCount = false,
|
||||
showMediaTypeIcon = false,
|
||||
showFavouriteIndicator = showFavouriteIndicator,
|
||||
)
|
||||
}
|
||||
+8
-1
@@ -294,7 +294,7 @@ The same calendar data drives a slide-in banner on open clients: when an episode
|
||||
time passes and Sonarr has not imported it yet, `/v1/status` starts returning an alert —
|
||||
|
||||
```json
|
||||
{"maintenance": false, "message": "", "alerts": [
|
||||
{"maintenance": false, "message": "", "notificationDisplay": "home_only", "alerts": [
|
||||
{"id": "sonarr:7:42:aired", "kind": "sonarr-aired", "title": "Northbound",
|
||||
"message": "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
"itemId": "sonarr:7:42", "imageTag": "sonarr", "airedAt": "2026-07-27T21:00:00+12:00"}]}
|
||||
@@ -310,6 +310,13 @@ The server has no idea which TVs have seen what, so the client owns that: it per
|
||||
ids it has shown and displays each alert once, for ten seconds, without taking focus — a
|
||||
ring on the banner counts that down, since the viewer cannot dismiss it by remote.
|
||||
|
||||
The global **Notification display** setting on Gateway settings controls every viewer and
|
||||
television. `everywhere` permits banners while browsing and over active playback;
|
||||
`home_only` permits browsing but not movies, episodes or trailers; `off` returns no banner
|
||||
alerts and the client renders none. Home only is the default for a new or older settings
|
||||
document, and clients also treat a missing or unknown value as Home only. The policy rides
|
||||
this status poll, so changing it takes effect on open sets without publishing an APK.
|
||||
|
||||
Clients poll only while a Memby screen is in the foreground, and record an alert as shown
|
||||
only when the banner is actually on screen. So keep offering an alert for the whole window
|
||||
rather than once: a TV that was showing its screensaver when the episode aired will pick
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
)
|
||||
|
||||
// adminGatewaySettingsResponse is deliberately three things at once: what the operator has
|
||||
// chosen, what the container was started with, and what is therefore in force. A settings
|
||||
// page that showed only the first would leave every empty field looking like a value of
|
||||
// nothing, when an empty field here means "whatever .env says" — and the operator has no
|
||||
// other way to see what that is without opening a file on the NAS.
|
||||
// chosen, the deployed or server defaults, and what is therefore in force. A settings page
|
||||
// that showed only the first would leave every empty environment-backed field looking like
|
||||
// a value of nothing, when it means "whatever .env says" — and the operator has no other
|
||||
// way to see that without opening a file on the NAS.
|
||||
type adminGatewaySettingsResponse struct {
|
||||
Settings store.GatewaySettings `json:"settings"`
|
||||
Deployed deployedGatewaySettings `json:"deployed"`
|
||||
@@ -23,6 +23,7 @@ type adminGatewaySettingsResponse struct {
|
||||
// for the reason the preference catalogue is served with the accounts page: a value
|
||||
// the server would refuse must never be offerable.
|
||||
LogLevels []string `json:"logLevels"`
|
||||
NotificationDisplays []string `json:"notificationDisplays"`
|
||||
// Version and Timezone name the process this page is about, so the page can identify
|
||||
// the gateway it is changing without a second request.
|
||||
Version string `json:"version"`
|
||||
@@ -39,11 +40,13 @@ func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
|
||||
SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)),
|
||||
SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute),
|
||||
RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute),
|
||||
NotificationDisplay: s.notificationDisplay(),
|
||||
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
|
||||
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
|
||||
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
|
||||
},
|
||||
LogLevels: store.GatewayLogLevels,
|
||||
NotificationDisplays: store.GatewayNotificationDisplays,
|
||||
Version: buildinfo.Version(),
|
||||
}
|
||||
}
|
||||
@@ -94,6 +97,7 @@ func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
s.loggerFor(r.Context()).Info("gateway settings changed",
|
||||
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
|
||||
"notification_display", s.notificationDisplay(),
|
||||
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
|
||||
"operator", operator)
|
||||
if summary := gatewaySettingsChanges(previous, stored); summary != "" {
|
||||
@@ -130,6 +134,9 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
|
||||
if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
|
||||
changes = append(changes, "film alert window")
|
||||
}
|
||||
if before.NotificationDisplay != after.NotificationDisplay {
|
||||
changes = append(changes, "notification display")
|
||||
}
|
||||
if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
|
||||
changes = append(changes, "Emby health probe")
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@ func (s *Server) radarrAlertWindow() time.Duration {
|
||||
s.cfg.RadarrAlertWindow)
|
||||
}
|
||||
|
||||
// notificationDisplay is the household-wide banner policy. The store normalises the
|
||||
// cached value, and this fallback keeps tests and a not-yet-primed server safe too.
|
||||
func (s *Server) notificationDisplay() string {
|
||||
switch display := s.gatewaySettings.get().NotificationDisplay; display {
|
||||
case store.NotificationDisplayEverywhere, store.NotificationDisplayOff:
|
||||
return display
|
||||
default:
|
||||
return store.NotificationDisplayHomeOnly
|
||||
}
|
||||
}
|
||||
|
||||
// slowRequestThreshold is the line between a request that logs a breakdown and one that
|
||||
// does not. Switched off it returns a duration no request can exceed rather than zero,
|
||||
// because zero would mean *every* request carried one — the opposite of what the operator
|
||||
@@ -166,15 +177,17 @@ func overrideWindow(value int, unit, deployed time.Duration) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
// deployedGatewaySettings describes what the container was started with, so the console
|
||||
// can show the value a cleared field falls back to. Deliberately not the same shape as
|
||||
// the overrides: these are facts, not choices, and nothing may write them back.
|
||||
// deployedGatewaySettings describes the defaults a cleared field falls back to: deployed
|
||||
// values for environment-backed settings, and Home only for the native notification
|
||||
// policy. Deliberately not the same shape as the stored document: these are facts, not
|
||||
// choices, and nothing may write them back.
|
||||
type deployedGatewaySettings struct {
|
||||
Timezone string `json:"timezone"`
|
||||
LogLevel string `json:"logLevel"`
|
||||
SessionIdleDays int `json:"sessionIdleDays"`
|
||||
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
|
||||
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
|
||||
NotificationDisplay string `json:"notificationDisplay"`
|
||||
EmbyHealthSeconds int `json:"embyHealthSeconds"`
|
||||
SlowRequestMillis int `json:"slowRequestMillis"`
|
||||
LibrarySyncMinutes int `json:"librarySyncMinutes"`
|
||||
@@ -191,6 +204,7 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
|
||||
SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)),
|
||||
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
|
||||
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
|
||||
NotificationDisplay: store.NotificationDisplayHomeOnly,
|
||||
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
|
||||
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
|
||||
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
|
||||
|
||||
@@ -54,6 +54,17 @@ func TestGatewaySettingsChangesReportsOnlyWhatMoved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationDisplayDefaultsToHomeOnly(t *testing.T) {
|
||||
s := &Server{}
|
||||
if got := s.notificationDisplay(); got != store.NotificationDisplayHomeOnly {
|
||||
t.Fatalf("unset notification display = %q, want %q", got, store.NotificationDisplayHomeOnly)
|
||||
}
|
||||
s.gatewaySettings.set(store.GatewaySettings{NotificationDisplay: store.NotificationDisplayEverywhere})
|
||||
if got := s.notificationDisplay(); got != store.NotificationDisplayEverywhere {
|
||||
t.Fatalf("notification display = %q, want everywhere", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelNameCoversTheVocabulary(t *testing.T) {
|
||||
for _, level := range store.GatewayLogLevels {
|
||||
if got := levelName(parseTestLevel(t, level)); got != level {
|
||||
|
||||
@@ -109,9 +109,10 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
message = store.DefaultMaintenanceMessage
|
||||
}
|
||||
alerts := []clientAlert{}
|
||||
notificationDisplay := s.notificationDisplay()
|
||||
// Nothing to celebrate while the service is down, and the client is showing the
|
||||
// maintenance screen anyway.
|
||||
if !state.Enabled {
|
||||
if !state.Enabled && notificationDisplay != store.NotificationDisplayOff {
|
||||
published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context())
|
||||
if len(published) > 0 || len(sonarr) > 0 {
|
||||
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID)
|
||||
@@ -136,6 +137,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
"quietTime": quiet.Active,
|
||||
"message": message,
|
||||
"alerts": alerts,
|
||||
"notificationDisplay": notificationDisplay,
|
||||
"compatible": compatible,
|
||||
"compatibilityMessage": compatibilityMessage,
|
||||
"clientVersion": clientVersion(r),
|
||||
|
||||
@@ -17,13 +17,14 @@ const GatewaySettingsKey = "gateway_settings"
|
||||
// GatewaySettings is the handful of server-level decisions an operator can change
|
||||
// without a redeployment.
|
||||
//
|
||||
// Every field is an *override* of the value the container was started with, and the zero
|
||||
// value means "whatever was deployed". That is the whole design: `.env` remains the
|
||||
// Most fields are an *override* of the value the container was started with, and their
|
||||
// zero value means "whatever was deployed". That is the whole design: `.env` remains the
|
||||
// configuration — it is what a fresh container comes up with, what deploy-server.ps1
|
||||
// writes and what an operator locked out of the console still has — and this row is a
|
||||
// runtime amendment to it, so the console can always show the deployed value beside the
|
||||
// one in force and an operator can put a setting back by clearing it rather than by
|
||||
// remembering what it used to be.
|
||||
// remembering what it used to be. NotificationDisplay is the exception: it is a native
|
||||
// server policy with a Home-only default rather than an environment-backed value.
|
||||
//
|
||||
// A window that can legitimately be *off* therefore needs a value distinct from "not
|
||||
// overridden", which is why the two alert windows and the health interval take -1 for off
|
||||
@@ -44,6 +45,10 @@ type GatewaySettings struct {
|
||||
// added" banner stays on offer to a television that was switched off at the time.
|
||||
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
|
||||
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
|
||||
// NotificationDisplay is where informational banners may be drawn across every
|
||||
// viewer and television. It is server-owned rather than a per-viewer preference:
|
||||
// one household should not have different interruption rules on different sets.
|
||||
NotificationDisplay string `json:"notificationDisplay"`
|
||||
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
|
||||
EmbyHealthSeconds int `json:"embyHealthSeconds"`
|
||||
// SlowRequestMillis is how long a request must take before its log line carries a
|
||||
@@ -74,6 +79,20 @@ const GatewaySettingsOff = -1
|
||||
// and refusing is worth nothing if the list it refuses against lives somewhere else.
|
||||
var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"}
|
||||
|
||||
const (
|
||||
NotificationDisplayEverywhere = "everywhere"
|
||||
NotificationDisplayHomeOnly = "home_only"
|
||||
NotificationDisplayOff = "off"
|
||||
)
|
||||
|
||||
// GatewayNotificationDisplays is the complete wire vocabulary, served to the console so
|
||||
// it cannot offer a value the gateway would refuse.
|
||||
var GatewayNotificationDisplays = []string{
|
||||
NotificationDisplayEverywhere,
|
||||
NotificationDisplayHomeOnly,
|
||||
NotificationDisplayOff,
|
||||
}
|
||||
|
||||
func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
|
||||
settings.Timezone = strings.TrimSpace(settings.Timezone)
|
||||
if settings.Timezone != "" {
|
||||
@@ -91,6 +110,13 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
|
||||
settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false)
|
||||
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
|
||||
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true)
|
||||
settings.NotificationDisplay = strings.ToLower(strings.TrimSpace(settings.NotificationDisplay))
|
||||
if !containsString(GatewayNotificationDisplays, settings.NotificationDisplay) {
|
||||
// Missing covers both a new installation and a row written by an older gateway.
|
||||
// Unknown is equally conservative: silence from the setting must never authorise
|
||||
// an interruption over active playback.
|
||||
settings.NotificationDisplay = NotificationDisplayHomeOnly
|
||||
}
|
||||
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
|
||||
// The floor is 1ms rather than 0 because "breakdown on everything" is a real thing to
|
||||
// want for a few minutes, and off is a real thing to want too — this is the one
|
||||
@@ -133,7 +159,7 @@ func (s *Store) GatewaySettings(ctx context.Context) (GatewaySettings, error) {
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GatewaySettings{}, nil
|
||||
return normalizeGatewaySettings(GatewaySettings{}), nil
|
||||
}
|
||||
if err != nil {
|
||||
return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err)
|
||||
|
||||
@@ -21,6 +21,25 @@ func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGatewaySettingsDefaultsNotificationDisplayToHomeOnly(t *testing.T) {
|
||||
for _, value := range []string{"", "unknown"} {
|
||||
settings := normalizeGatewaySettings(GatewaySettings{NotificationDisplay: value})
|
||||
if settings.NotificationDisplay != NotificationDisplayHomeOnly {
|
||||
t.Fatalf("notification display %q became %q, want %q",
|
||||
value, settings.NotificationDisplay, NotificationDisplayHomeOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGatewaySettingsKeepsNotificationDisplayVocabulary(t *testing.T) {
|
||||
for _, value := range GatewayNotificationDisplays {
|
||||
settings := normalizeGatewaySettings(GatewaySettings{NotificationDisplay: " " + value + " "})
|
||||
if settings.NotificationDisplay != value {
|
||||
t.Fatalf("notification display %q became %q", value, settings.NotificationDisplay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGatewaySettingsKeepsTheThreeMeanings(t *testing.T) {
|
||||
settings := normalizeGatewaySettings(GatewaySettings{
|
||||
Timezone: "Pacific/Auckland", LogLevel: " Debug ",
|
||||
|
||||
Reference in New Issue
Block a user