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
|
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
|
||||||
|
|||||||
-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"
|
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 ",
|
||||||
|
|||||||
Reference in New Issue
Block a user