This commit is contained in:
ponzischeme89
2026-08-22 21:47:54 +12:00
parent d2f7f84cbf
commit 9a1f44da46
27 changed files with 452 additions and 110 deletions
+15 -5
View File
@@ -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 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 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 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 `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 `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 and alerts are suppressed under maintenance and under a mandatory update, which own the
screen. screen.
**The bar appears over playback too**, not only over the launcher: `PlayerActivity` **Where the bar may appear is the server's decision.** `GatewaySettings.NotificationDisplay`
mounts the same composable in a `ComposeView` (`player_service_alerts` in is `everywhere`, `home_only` (the default) or `off`, and `/v1/status` carries the effective
`activity_player.xml`, declared before the loading and error overlays so those cover it). 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 `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 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 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 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 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 no artwork, and one constant mark reads as "your server is talking" where a poster made
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
rel="icon" 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" 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="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
</head> </head>
+6 -3
View File
@@ -757,15 +757,16 @@ export interface GoroutineReport {
/* ---------- gateway settings ---------- */ /* ---------- gateway settings ---------- */
/** GatewaySettings is the operator's overrides. Every field is optional in meaning: an /** GatewaySettings is the operator's server document. Environment-backed fields use an
* empty string or a zero means "whatever the container was started with", and -1 means * empty string or zero for "whatever the container was started with"; notification
* off for the three that can be switched off. */ * display is native to this document and defaults to home only. */
export interface GatewaySettings { export interface GatewaySettings {
timezone: string; timezone: string;
logLevel: string; logLevel: string;
sessionIdleDays: number; sessionIdleDays: number;
sonarrAlertMinutes: number; sonarrAlertMinutes: number;
radarrAlertMinutes: number; radarrAlertMinutes: number;
notificationDisplay: string;
embyHealthSeconds: number; embyHealthSeconds: number;
slowRequestMillis: number; slowRequestMillis: number;
librarySyncMinutes: number; librarySyncMinutes: number;
@@ -781,6 +782,7 @@ export interface GatewaySettingValues {
sessionIdleDays: number; sessionIdleDays: number;
sonarrAlertMinutes: number; sonarrAlertMinutes: number;
radarrAlertMinutes: number; radarrAlertMinutes: number;
notificationDisplay: string;
embyHealthSeconds: number; embyHealthSeconds: number;
slowRequestMillis: number; slowRequestMillis: number;
librarySyncMinutes: number; librarySyncMinutes: number;
@@ -791,6 +793,7 @@ export interface GatewaySettingsResponse {
deployed: GatewaySettingValues; deployed: GatewaySettingValues;
effective: GatewaySettingValues; effective: GatewaySettingValues;
logLevels: string[] | null; logLevels: string[] | null;
notificationDisplays: string[] | null;
version: string; version: string;
} }
+40 -13
View File
@@ -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 * 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. * 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 * Most fields amend `.env`: blank means "whatever this container was started with", which
* means "whatever this container was started with", which is printed beside it so an * is printed beside the field. Notification display is server-native instead and has a
* operator can always see what they are overriding, and clearing a field is a real undo * Home-only default. */
* rather than a value they have to remember. */
/** OVERRIDE_OFF is the wire value for "switched off", which the three window settings need /** OVERRIDE_OFF is the wire value for "switched off", which the three window settings need
* to distinguish from an empty field. See store.GatewaySettingsOff. */ * 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'}`; 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 { interface Draft {
timezone: string; timezone: string;
logLevel: string; logLevel: string;
sessionIdleDays: string; sessionIdleDays: string;
sonarrAlertMinutes: string; sonarrAlertMinutes: string;
radarrAlertMinutes: string; radarrAlertMinutes: string;
notificationDisplay: string;
embyHealthSeconds: string; embyHealthSeconds: string;
slowRequestMillis: string; slowRequestMillis: string;
librarySyncMinutes: string; librarySyncMinutes: string;
@@ -64,6 +72,7 @@ function draftFrom(settings: GatewaySettings): Draft {
sessionIdleDays: numberFieldValue(settings.sessionIdleDays), sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes), sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes), radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
notificationDisplay: settings.notificationDisplay || 'home_only',
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds), embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
slowRequestMillis: numberFieldValue(settings.slowRequestMillis), slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes), librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
@@ -95,6 +104,7 @@ export function SettingsPage() {
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false), sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true), sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true), radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
notificationDisplay: draft.notificationDisplay,
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true), embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true), slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true), librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
@@ -117,9 +127,9 @@ export function SettingsPage() {
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', { () => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0, timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 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)); if (saved) setDraft(draftFrom(saved.settings));
await reload(); await reload();
@@ -128,6 +138,7 @@ export function SettingsPage() {
const deployed = data?.deployed; const deployed = data?.deployed;
const effective = data?.effective; const effective = data?.effective;
const levels = data?.logLevels ?? []; const levels = data?.logLevels ?? [];
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
return ( return (
<> <>
@@ -158,13 +169,14 @@ export function SettingsPage() {
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') }, { label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') }, { label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') }, { label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
]} ]}
/> />
</Card> </Card>
<Card <Card
title="Overrides" title="Server settings"
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." 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" icon="sliders"
tone="note" tone="note"
footer={ footer={
@@ -173,7 +185,7 @@ export function SettingsPage() {
Save settings Save settings
</Button> </Button>
<Button busy={busy === 'clear'} onClick={() => void clearAll()}> <Button busy={busy === 'clear'} onClick={() => void clearAll()}>
Use deployed values Use defaults
</Button> </Button>
</> </>
} }
@@ -262,6 +274,22 @@ export function SettingsPage() {
</Field> </Field>
</div> </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"> <div className="fields">
<Field <Field
label="Episode alert window (minutes)" label="Episode alert window (minutes)"
@@ -291,10 +319,9 @@ export function SettingsPage() {
</div> </div>
<Note tone="note"> <Note tone="note">
These override the deployed configuration in the database, so they survive a These settings live in the database and survive a restart. A deployment rewrites
restart but a deployment rewrites <code>.env</code>, not this, and the two can <code>.env</code>, not this document, so environment-backed values can then disagree;
then disagree. Anything meant to be permanent belongs in <code>.env.example</code>{' '} permanent environment changes belong in <code>.env.example</code> as well.
as well.
</Note> </Note>
{data?.settings.updatedBy ? ( {data?.settings.updatedBy ? (
<Note> <Note>
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.04" val defaultVersionName = "0.3.05"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -39,7 +39,7 @@ data class EmbyOutage(
/** /**
* One informational banner: a show aired, a film was added, the library finished * 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 * 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 * [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 * 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) private val _alert = MutableStateFlow<ServiceAlert?>(null)
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow() 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) private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)
/** Null whenever Emby is answering, or when nothing is watching it. */ /** Null whenever Emby is answering, or when nothing is watching it. */
@@ -296,6 +299,7 @@ class MaintenanceMonitor(
_viewersEnabled.value = false _viewersEnabled.value = false
setRequestsAllowed(false) setRequestsAllowed(false)
_gatewayVersion.value = "" _gatewayVersion.value = ""
_notificationDisplay.value = NotificationDisplayPolicy.HOME_ONLY
dismissAlert() dismissAlert()
return@collectLatest return@collectLatest
} }
@@ -344,6 +348,8 @@ class MaintenanceMonitor(
_viewersEnabled.value = status.features[VIEWERS_FEATURE] == true _viewersEnabled.value = status.features[VIEWERS_FEATURE] == true
setRequestsAllowed(status.requests.allowed) setRequestsAllowed(status.requests.allowed)
_gatewayVersion.value = status.gatewayVersion _gatewayVersion.value = status.gatewayVersion
_notificationDisplay.value =
NotificationDisplayPolicy.fromWire(status.notificationDisplay)
// Emby's state is reported even during maintenance: an // Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable // operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll. // should not have that fact disappear from the poll.
@@ -387,6 +393,7 @@ class MaintenanceMonitor(
_viewersEnabled.value = false _viewersEnabled.value = false
setRequestsAllowed(false) setRequestsAllowed(false)
_gatewayVersion.value = "" _gatewayVersion.value = ""
_notificationDisplay.value = NotificationDisplayPolicy.HOME_ONLY
dismissAlert() dismissAlert()
return@collectLatest 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 maintenance: Boolean = false,
val message: String = "", val message: String = "",
val alerts: List<GatewayAlert> = emptyList(), 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 compatible: Boolean = true,
val compatibilityMessage: String = "", val compatibilityMessage: String = "",
val clientVersion: String = "", val clientVersion: String = "",
@@ -2,10 +2,10 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -28,12 +28,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CornerRadius
import androidx.compose.ui.graphics.ImageBitmap 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.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight 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" 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. * 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 // 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. // one as a movie is worse than one announcing it by the caption it is wearing.
contentDescription = "Featured, ${pick.label}, ${item.name}", contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), modifier = modifier,
) { focused -> ) { focused ->
Box( HeroFocusFrame(
Modifier focused = focused,
.fillMaxSize() modifier = Modifier.fillMaxSize(),
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyPanelCorner),
),
) { ) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box( Box(
@@ -547,17 +548,11 @@ private fun MiniMovieCard(
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = onClick,
contentDescription = "${pick.label} movie, ${item.name}", contentDescription = "${pick.label} movie, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), modifier = modifier,
) { focused -> ) { focused ->
Box( HeroFocusFrame(
Modifier focused = focused,
.fillMaxSize() modifier = Modifier.fillMaxSize(),
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyPanelCorner),
),
) { ) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(labelTint(pick.label))) 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 @Composable
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) { private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
if (previewArtwork != null) { if (previewArtwork != null) {
@@ -68,6 +68,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.NotificationSurface
import com.ponzischeme89.memby.data.StartupPosterSnapshotCache import com.ponzischeme89.memby.data.StartupPosterSnapshotCache
import com.ponzischeme89.memby.data.StartupPosterSource import com.ponzischeme89.memby.data.StartupPosterSource
import com.ponzischeme89.memby.data.friendlyEmbyError 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 // News about the library, not about the app: it sits above the rows but is
// suppressed whenever something more important owns the screen. // suppressed whenever something more important owns the screen.
ServiceAlertBanner( ServiceAlertBanner(
surface = NotificationSurface.BROWSING,
// The alert itself is collected inside the banner, so an arriving one does // The alert itself is collected inside the banner, so an arriving one does
// not recompose this whole function. Only the suppression conditions — both // not recompose this whole function. Only the suppression conditions — both
// already read here for other reasons — cross the boundary. // 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.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.MaintenanceMonitor import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.NotificationSurface
import com.ponzischeme89.memby.data.ServiceAlert import com.ponzischeme89.memby.data.ServiceAlert
import com.ponzischeme89.memby.data.notificationEligible
import kotlin.math.ceil import kotlin.math.ceil
import com.ponzischeme89.memby.ui.theme.MembyAccent 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. * 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 * Kept to a strip rather than a panel because the server may permit it over playback as
* over the launcher: whatever it says, it is covering somebody's film while it says it. * well as over the launcher.
*/ */
private val BannerHeight = 48.dp private val BannerHeight = 48.dp
@@ -83,12 +85,19 @@ private val SafeAreaHorizontal = 48.dp
* allowed to own the whole display. * allowed to own the whole display.
*/ */
@Composable @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. // 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 // 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. // body; read here and it recomposes a bar that is usually not even on screen.
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle() 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 // 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 // alert is only offered — it starts no dismissal timer and persists nothing — so an
@@ -131,6 +131,7 @@ fun LandscapeCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
density: String = "standard", density: String = "standard",
showWatchedEpisodeCount: Boolean = false, showWatchedEpisodeCount: Boolean = false,
showFavouriteIndicator: Boolean = true,
) { ) {
val cardsAcross = when (density) { val cardsAcross = when (density) {
"compact" -> 5 "compact" -> 5
@@ -138,12 +139,13 @@ fun LandscapeCard(
else -> 4 else -> 4
} }
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp) val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount) { val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount, showFavouriteIndicator) {
item.toMediaCardUiModel( item.toMediaCardUiModel(
showProgress = false, showProgress = false,
showSecondaryMetadata, showSecondaryMetadata,
showWatchedEpisodeCount, showWatchedEpisodeCount,
showMediaTypeIcon = false, showMediaTypeIcon = false,
showFavouriteIndicator = showFavouriteIndicator,
) )
} }
MediaCard( MediaCard(
@@ -107,12 +107,15 @@ internal data class MediaCardUiModel(
/** /**
* @param showProgress whether a "Resume at …" subtitle should replace the default one when * @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. * 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( internal fun BaseItem.toMediaCardUiModel(
showProgress: Boolean, showProgress: Boolean,
showSecondaryMetadata: Boolean, showSecondaryMetadata: Boolean,
showWatchedEpisodeCount: Boolean, showWatchedEpisodeCount: Boolean,
showMediaTypeIcon: Boolean, showMediaTypeIcon: Boolean,
showFavouriteIndicator: Boolean,
): MediaCardUiModel { ): MediaCardUiModel {
val position = userData?.playbackPositionTicks ?: 0L val position = userData?.playbackPositionTicks ?: 0L
val runtime = runTimeTicks ?: 0L val runtime = runTimeTicks ?: 0L
@@ -128,7 +131,7 @@ internal fun BaseItem.toMediaCardUiModel(
showSecondaryMetadata = showSecondaryMetadata, showSecondaryMetadata = showSecondaryMetadata,
contentDescription = cardDescription(this, progress, showWatchedEpisodeCount, showMediaTypeIcon), contentDescription = cardDescription(this, progress, showWatchedEpisodeCount, showMediaTypeIcon),
played = userData?.played == true, played = userData?.played == true,
favourite = isFavorite, favourite = isFavorite && showFavouriteIndicator,
progress = progress, progress = progress,
isSchedule = isSchedule, isSchedule = isSchedule,
scheduleAvailability = membyAvailability.orEmpty(), scheduleAvailability = membyAvailability.orEmpty(),
@@ -152,6 +155,7 @@ fun PosterCard(
density: String = "standard", density: String = "standard",
showWatchedEpisodeCount: Boolean = false, showWatchedEpisodeCount: Boolean = false,
showProgress: Boolean = false, showProgress: Boolean = false,
showFavouriteIndicator: Boolean = true,
) { ) {
val cardsAcross = when (density) { val cardsAcross = when (density) {
"compact" -> 8 "compact" -> 8
@@ -159,8 +163,20 @@ fun PosterCard(
else -> 7 else -> 7
} }
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp) val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
val model = remember(item, showProgress, showSecondaryMetadata, showWatchedEpisodeCount) { val model = remember(
item.toMediaCardUiModel(showProgress, showSecondaryMetadata, showWatchedEpisodeCount, showMediaTypeIcon = false) item,
showProgress,
showSecondaryMetadata,
showWatchedEpisodeCount,
showFavouriteIndicator,
) {
item.toMediaCardUiModel(
showProgress,
showSecondaryMetadata,
showWatchedEpisodeCount,
showMediaTypeIcon = false,
showFavouriteIndicator = showFavouriteIndicator,
)
} }
MediaCard( MediaCard(
model, item, width, 2f / 3f, preferPrimary = true, showProgress, model, item, width, 2f / 3f, preferPrimary = true, showProgress,
@@ -192,6 +208,7 @@ fun PosterGridCard(
showSecondaryMetadata = true, showSecondaryMetadata = true,
showWatchedEpisodeCount, showWatchedEpisodeCount,
showMediaTypeIcon, showMediaTypeIcon,
showFavouriteIndicator = true,
) )
} }
MediaCard( MediaCard(
@@ -319,12 +319,14 @@ internal fun MediaRow(
item, availableWidth, row.showSecondaryMetadata, focused, item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount, density, row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
) )
MediaCardFormat.LANDSCAPE -> { MediaCardFormat.LANDSCAPE -> {
LandscapeCard( LandscapeCard(
item, availableWidth, row.showSecondaryMetadata, focused, item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount, 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.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.creditsWorthShowing import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.NotificationSurface
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
import com.ponzischeme89.memby.data.Playable import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle 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 * Under Everywhere, news about the server can explain a stall the viewer is looking at
* from Emby, so "the server has stopped communicating" explains a stall the viewer is * right now. The bar is never focusable, so the transport controls keep the remote; it
* looking at right now. The bar is never focusable, so the transport controls keep the * times itself out rather than asking for a press. Home only and Off remain eligible
* remote; it times itself out rather than asking for a press. * nowhere in this activity through the shared notification rule.
* *
* [alertsSuppressed] is what stops an alert being *used up* behind the loading or * [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 * error overlay: the banner records an alert as seen the moment it composes, and the
@@ -4675,6 +4676,7 @@ class PlayerActivity : ComponentActivity() {
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
EmbyOutageBanner(suppressed = alertsSuppressed.value) EmbyOutageBanner(suppressed = alertsSuppressed.value)
ServiceAlertBanner( ServiceAlertBanner(
surface = NotificationSurface.PLAYBACK,
suppressed = alertsSuppressed.value || outage != null, suppressed = alertsSuppressed.value || outage != null,
) )
} }
@@ -15,6 +15,30 @@ class ServiceAlertTest {
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") = private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message) 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 @Test
fun `picks the first alert this tv has not seen`() { fun `picks the first alert this tv has not seen`() {
val chosen = firstUnseenAlert( val chosen = firstUnseenAlert(
@@ -68,6 +92,7 @@ class ServiceAlertTest {
"""{"maintenance":false,"message":""}""", """{"maintenance":false,"message":""}""",
) )
assertTrue(status.alerts.isEmpty()) assertTrue(status.alerts.isEmpty())
assertEquals("home_only", status.notificationDisplay)
} }
@Test @Test
@@ -89,6 +114,18 @@ class ServiceAlertTest {
assertEquals("", alert.label) 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 @Test
fun `status decodes a radarr import alert with its own wording`() { fun `status decodes a radarr import alert with its own wording`() {
val status = json.decodeFromString<GatewayServiceStatus>( 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.focus.FocusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow 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.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@@ -48,6 +54,7 @@ import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") @Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class HomeMovieHeroScreenshotTest { class HomeMovieHeroScreenshotTest {
@get:Rule @get:Rule
val compose = createComposeRule() val compose = createComposeRule()
@@ -62,6 +69,25 @@ class HomeMovieHeroScreenshotTest {
capture("df_home-movie-hero", movies) 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 * 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 * 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>) { 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")) val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream) .use(BitmapFactory::decodeStream)
.asImageBitmap() .asImageBitmap()
@@ -364,7 +395,6 @@ class HomeMovieHeroScreenshotTest {
} }
assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty()) assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty())
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
} }
private val movies = listOf( 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
View File
@@ -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 — time passes and Sonarr has not imported it yet, `/v1/status` starts returning an alert —
```json ```json
{"maintenance": false, "message": "", "alerts": [ {"maintenance": false, "message": "", "notificationDisplay": "home_only", "alerts": [
{"id": "sonarr:7:42:aired", "kind": "sonarr-aired", "title": "Northbound", {"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.", "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"}]} "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 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. 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 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 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 rather than once: a TV that was showing its screensaver when the episode aired will pick
+11 -4
View File
@@ -11,10 +11,10 @@ import (
) )
// adminGatewaySettingsResponse is deliberately three things at once: what the operator has // 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 // chosen, the deployed or server defaults, and what is therefore in force. A settings page
// page that showed only the first would leave every empty field looking like a value of // that showed only the first would leave every empty environment-backed field looking like
// nothing, when an empty field here means "whatever .env says" — and the operator has no // a value of nothing, when it means "whatever .env says" — and the operator has no other
// other way to see what that is without opening a file on the NAS. // way to see that without opening a file on the NAS.
type adminGatewaySettingsResponse struct { type adminGatewaySettingsResponse struct {
Settings store.GatewaySettings `json:"settings"` Settings store.GatewaySettings `json:"settings"`
Deployed deployedGatewaySettings `json:"deployed"` 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 // for the reason the preference catalogue is served with the accounts page: a value
// the server would refuse must never be offerable. // the server would refuse must never be offerable.
LogLevels []string `json:"logLevels"` LogLevels []string `json:"logLevels"`
NotificationDisplays []string `json:"notificationDisplays"`
// Version and Timezone name the process this page is about, so the page can identify // Version and Timezone name the process this page is about, so the page can identify
// the gateway it is changing without a second request. // the gateway it is changing without a second request.
Version string `json:"version"` Version string `json:"version"`
@@ -39,11 +40,13 @@ func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)), SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)),
SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute), SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute),
RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute), RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute),
NotificationDisplay: s.notificationDisplay(),
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second), EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()), SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute), LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
}, },
LogLevels: store.GatewayLogLevels, LogLevels: store.GatewayLogLevels,
NotificationDisplays: store.GatewayNotificationDisplays,
Version: buildinfo.Version(), 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", s.loggerFor(r.Context()).Info("gateway settings changed",
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(), "timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
"notification_display", s.notificationDisplay(),
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)), "session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
"operator", operator) "operator", operator)
if summary := gatewaySettingsChanges(previous, stored); summary != "" { if summary := gatewaySettingsChanges(previous, stored); summary != "" {
@@ -130,6 +134,9 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
if before.RadarrAlertMinutes != after.RadarrAlertMinutes { if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
changes = append(changes, "film alert window") changes = append(changes, "film alert window")
} }
if before.NotificationDisplay != after.NotificationDisplay {
changes = append(changes, "notification display")
}
if before.EmbyHealthSeconds != after.EmbyHealthSeconds { if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
changes = append(changes, "Emby health probe") changes = append(changes, "Emby health probe")
} }
+17 -3
View File
@@ -125,6 +125,17 @@ func (s *Server) radarrAlertWindow() time.Duration {
s.cfg.RadarrAlertWindow) 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 // 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, // 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 // 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 // deployedGatewaySettings describes the defaults a cleared field falls back to: deployed
// can show the value a cleared field falls back to. Deliberately not the same shape as // values for environment-backed settings, and Home only for the native notification
// the overrides: these are facts, not choices, and nothing may write them back. // policy. Deliberately not the same shape as the stored document: these are facts, not
// choices, and nothing may write them back.
type deployedGatewaySettings struct { type deployedGatewaySettings struct {
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
LogLevel string `json:"logLevel"` LogLevel string `json:"logLevel"`
SessionIdleDays int `json:"sessionIdleDays"` SessionIdleDays int `json:"sessionIdleDays"`
SonarrAlertMinutes int `json:"sonarrAlertMinutes"` SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"` RadarrAlertMinutes int `json:"radarrAlertMinutes"`
NotificationDisplay string `json:"notificationDisplay"`
EmbyHealthSeconds int `json:"embyHealthSeconds"` EmbyHealthSeconds int `json:"embyHealthSeconds"`
SlowRequestMillis int `json:"slowRequestMillis"` SlowRequestMillis int `json:"slowRequestMillis"`
LibrarySyncMinutes int `json:"librarySyncMinutes"` LibrarySyncMinutes int `json:"librarySyncMinutes"`
@@ -191,6 +204,7 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)), SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)),
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute), SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute), RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
NotificationDisplay: store.NotificationDisplayHomeOnly,
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second), EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond), SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute), 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) { func TestLevelNameCoversTheVocabulary(t *testing.T) {
for _, level := range store.GatewayLogLevels { for _, level := range store.GatewayLogLevels {
if got := levelName(parseTestLevel(t, level)); got != level { if got := levelName(parseTestLevel(t, level)); got != level {
+3 -1
View File
@@ -109,9 +109,10 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
message = store.DefaultMaintenanceMessage message = store.DefaultMaintenanceMessage
} }
alerts := []clientAlert{} alerts := []clientAlert{}
notificationDisplay := s.notificationDisplay()
// Nothing to celebrate while the service is down, and the client is showing the // Nothing to celebrate while the service is down, and the client is showing the
// maintenance screen anyway. // maintenance screen anyway.
if !state.Enabled { if !state.Enabled && notificationDisplay != store.NotificationDisplayOff {
published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context()) published, sonarr := s.publishedAlerts(r.Context()), s.sonarrAiredAlerts(r.Context())
if len(published) > 0 || len(sonarr) > 0 { if len(published) > 0 || len(sonarr) > 0 {
prefs, err := s.notificationPreferencesFor(r.Context(), sess.EmbyUserID) 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, "quietTime": quiet.Active,
"message": message, "message": message,
"alerts": alerts, "alerts": alerts,
"notificationDisplay": notificationDisplay,
"compatible": compatible, "compatible": compatible,
"compatibilityMessage": compatibilityMessage, "compatibilityMessage": compatibilityMessage,
"clientVersion": clientVersion(r), "clientVersion": clientVersion(r),
+30 -4
View File
@@ -17,13 +17,14 @@ const GatewaySettingsKey = "gateway_settings"
// GatewaySettings is the handful of server-level decisions an operator can change // GatewaySettings is the handful of server-level decisions an operator can change
// without a redeployment. // without a redeployment.
// //
// Every field is an *override* of the value the container was started with, and the zero // Most fields are an *override* of the value the container was started with, and their
// value means "whatever was deployed". That is the whole design: `.env` remains the // 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 // 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 // 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 // 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 // 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 // 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 // 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. // added" banner stays on offer to a television that was switched off at the time.
SonarrAlertMinutes int `json:"sonarrAlertMinutes"` SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"` 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 is how often the reachability probe asks Emby whether it is there.
EmbyHealthSeconds int `json:"embyHealthSeconds"` EmbyHealthSeconds int `json:"embyHealthSeconds"`
// SlowRequestMillis is how long a request must take before its log line carries a // 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. // and refusing is worth nothing if the list it refuses against lives somewhere else.
var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"} 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 { func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.Timezone = strings.TrimSpace(settings.Timezone) settings.Timezone = strings.TrimSpace(settings.Timezone)
if settings.Timezone != "" { if settings.Timezone != "" {
@@ -91,6 +110,13 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false) settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false)
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true) settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*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) settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
// The floor is 1ms rather than 0 because "breakdown on everything" is a real thing to // 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 // 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, err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw) `SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return GatewaySettings{}, nil return normalizeGatewaySettings(GatewaySettings{}), nil
} }
if err != nil { if err != nil {
return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err) 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) { func TestNormalizeGatewaySettingsKeepsTheThreeMeanings(t *testing.T) {
settings := normalizeGatewaySettings(GatewaySettings{ settings := normalizeGatewaySettings(GatewaySettings{
Timezone: "Pacific/Auckland", LogLevel: " Debug ", Timezone: "Pacific/Auckland", LogLevel: " Debug ",