0.3.07
This commit is contained in:
@@ -3379,11 +3379,13 @@ Things to preserve:
|
|||||||
or a completion reminder can use the first without inheriting the second. Near dates are
|
or a completion reminder can use the first without inheriting the second. Near dates are
|
||||||
named and far ones rounded to weeks or months — a pace measured over a fortnight cannot
|
named and far ones rounded to weeks or months — a pace measured over a fortnight cannot
|
||||||
honestly pick a day four months out.
|
honestly pick a day four months out.
|
||||||
- **"Catch up" is not a synonym for "finish".** `BaseItem.isOngoingSeries` prefers Sonarr's
|
- **"Catch up" is not a synonym for "finish".** `Status` is the canonical series-detail
|
||||||
lifecycle where the gateway attached one and falls back to Emby's `Status`, which is the
|
field on both paths. The gateway replaces Emby's value with the latest recognised Sonarr
|
||||||
only source the direct path has; both absent means "finish", the weaker claim. `Status`
|
observation stored by the existing daily lifecycle scan, while direct mode keeps Emby as
|
||||||
is in `fieldsDetail` on both paths for this, and the gateway's item cache key moved to
|
its only source; both absent means "finish", the weaker claim. The scan history's maximum
|
||||||
`item:v5:` so entries written before it cannot hide the field.
|
id versions the gateway item cache (`item:v7:r<revision>`) and rides `/v1/status`, so both
|
||||||
|
Redis and an open television's in-memory detail cache move when Sonarr does. Schedule-card
|
||||||
|
lifecycle metadata is only the opening-frame fallback before the full record arrives.
|
||||||
- **Quiet is the property a unit test cannot check**, so `SeriesPaceScreenshotTest` renders
|
- **Quiet is the property a unit test cannot check**, so `SeriesPaceScreenshotTest` renders
|
||||||
the line on a real hero → `build/screenshots/series-pace/`. It covers both spacing cases
|
the line on a real hero → `build/screenshots/series-pace/`. It covers both spacing cases
|
||||||
(with a progress bar above it and without), both verbs, and — the one worth keeping — the
|
(with a progress bar above it and without), both verbs, and — the one worth keeping — the
|
||||||
|
|||||||
+4
-4
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-C6xrkGsh.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-D2yWw-VA.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>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { SearchesPage } from './pages/Searches';
|
|||||||
import { ViewsPage } from './pages/Views';
|
import { ViewsPage } from './pages/Views';
|
||||||
import { MediaReportsPage } from './pages/MediaReports';
|
import { MediaReportsPage } from './pages/MediaReports';
|
||||||
import { NotificationsPage } from './pages/Notifications';
|
import { NotificationsPage } from './pages/Notifications';
|
||||||
|
import { NotificationSettingsPage } from './pages/NotificationSettings';
|
||||||
import { CreditsPage } from './pages/Credits';
|
import { CreditsPage } from './pages/Credits';
|
||||||
|
|
||||||
/* The console's routing table.
|
/* The console's routing table.
|
||||||
@@ -109,6 +110,7 @@ export function App() {
|
|||||||
<Route path="searches" element={<SearchesPage />} />
|
<Route path="searches" element={<SearchesPage />} />
|
||||||
<Route path="media-reports" element={<MediaReportsPage />} />
|
<Route path="media-reports" element={<MediaReportsPage />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
|
<Route path="notification-settings" element={<NotificationSettingsPage />} />
|
||||||
|
|
||||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||||
still links there lands on the overview rather than on a 404. */}
|
still links there lands on the overview rather than on a 404. */}
|
||||||
|
|||||||
@@ -797,6 +797,22 @@ export interface GatewaySettingsResponse {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- television notifications ---------- */
|
||||||
|
|
||||||
|
/** NotificationPreferences is the per-person policy the gateway applies before anything
|
||||||
|
* reaches My Alerts or an informational banner on a television. */
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
enabled: boolean;
|
||||||
|
showReturnAlerts: boolean;
|
||||||
|
sonarrAlerts: boolean;
|
||||||
|
radarrAlerts: boolean;
|
||||||
|
updateAlerts: boolean;
|
||||||
|
libraryAlerts: boolean;
|
||||||
|
systemAlerts: boolean;
|
||||||
|
watchTimeDigest: boolean;
|
||||||
|
leadDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- metadata hero ---------- */
|
/* ---------- metadata hero ---------- */
|
||||||
|
|
||||||
export interface MetadataHeroOption {
|
export interface MetadataHeroOption {
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ export const nav: NavGroup[] = [
|
|||||||
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
|
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
|
||||||
icon: 'send',
|
icon: 'send',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'notification-settings',
|
||||||
|
path: '/admin/notification-settings',
|
||||||
|
label: 'TV notifications',
|
||||||
|
title: 'TV notifications',
|
||||||
|
intro: 'Choose where notifications appear and who receives them.',
|
||||||
|
icon: 'bell',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'media-reports',
|
id: 'media-reports',
|
||||||
path: '/admin/media-reports',
|
path: '/admin/media-reports',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
Tiles,
|
Tiles,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
import type { DeviceVersion } from '../api/types';
|
import type { DeviceVersion, NotificationPreferences } from '../api/types';
|
||||||
|
|
||||||
/* One person: their televisions, their recommendation setup and the settings that follow
|
/* One person: their televisions, their recommendation setup and the settings that follow
|
||||||
them to every set. The identity is in the URL rather than in a query string so the page
|
them to every set. The identity is in the URL rather than in a query string so the page
|
||||||
@@ -95,18 +95,6 @@ interface RecommendationState {
|
|||||||
contentTypes?: string[];
|
contentTypes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationPreferences {
|
|
||||||
enabled: boolean;
|
|
||||||
showReturnAlerts: boolean;
|
|
||||||
sonarrAlerts: boolean;
|
|
||||||
radarrAlerts: boolean;
|
|
||||||
updateAlerts: boolean;
|
|
||||||
libraryAlerts: boolean;
|
|
||||||
systemAlerts: boolean;
|
|
||||||
watchTimeDigest: boolean;
|
|
||||||
leadDays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tracearr's reading of this person. `matched` separates "no Tracearr, or nobody by this
|
/* Tracearr's reading of this person. `matched` separates "no Tracearr, or nobody by this
|
||||||
name in it" from "has watched nothing", which are the same row of zeroes on the wire and
|
name in it" from "has watched nothing", which are the same row of zeroes on the wire and
|
||||||
very different things for an operator to be told. */
|
very different things for an operator to be told. */
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type {
|
||||||
|
GatewaySettingsResponse,
|
||||||
|
NotificationPreferences,
|
||||||
|
} from '../api/types';
|
||||||
|
import { Banner, Button, Card, Grid, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||||
|
import { useAction, useQuery } from '../lib/hooks';
|
||||||
|
import { useToast } from '../lib/toast';
|
||||||
|
|
||||||
|
interface NotificationAccount {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
shortName?: string;
|
||||||
|
notifications: NotificationPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountsResponse {
|
||||||
|
accounts: NotificationAccount[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayChoices = [
|
||||||
|
{
|
||||||
|
value: 'everywhere',
|
||||||
|
label: 'Everywhere',
|
||||||
|
description: 'Show informational banners over the home screen and active playback.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'home_only',
|
||||||
|
label: 'Home only',
|
||||||
|
description: 'Show banners on the launcher, without interrupting a film or programme.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'off',
|
||||||
|
label: 'Off',
|
||||||
|
description: 'Do not show informational banners on any television.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A dedicated control surface for what televisions show. The notification history keeps
|
||||||
|
* answering what happened; this page answers what is allowed to happen next. */
|
||||||
|
export function NotificationSettingsPage() {
|
||||||
|
const gateway = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
|
||||||
|
const accounts = useQuery<AccountsResponse>('/admin/api/accounts');
|
||||||
|
const { busy, run } = useAction();
|
||||||
|
const { wrap } = useToast();
|
||||||
|
const [display, setDisplay] = useState<string | null>(null);
|
||||||
|
const [preferences, setPreferences] = useState<Record<string, NotificationPreferences>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (display === null && gateway.data) {
|
||||||
|
setDisplay(gateway.data.settings.notificationDisplay || 'home_only');
|
||||||
|
}
|
||||||
|
}, [display, gateway.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loaded = accounts.data;
|
||||||
|
if (!loaded) return;
|
||||||
|
setPreferences((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const account of loaded.accounts ?? []) {
|
||||||
|
if (!next[account.id]) next[account.id] = { ...account.notifications };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [accounts.data]);
|
||||||
|
|
||||||
|
const saveDisplay = () =>
|
||||||
|
run('display', async () => {
|
||||||
|
if (!gateway.data || display === null) return;
|
||||||
|
const saved = await wrap(
|
||||||
|
async () => {
|
||||||
|
// The endpoint stores one server-settings document. Read it again immediately
|
||||||
|
// before changing this field so a save in another console tab is not replaced
|
||||||
|
// by the older copy this page originally loaded.
|
||||||
|
const latest = await api.get<GatewaySettingsResponse>('/admin/api/gateway-settings');
|
||||||
|
return api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
|
||||||
|
...latest.settings,
|
||||||
|
notificationDisplay: display,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'Television notification display saved.',
|
||||||
|
);
|
||||||
|
if (saved) {
|
||||||
|
gateway.set(saved);
|
||||||
|
setDisplay(saved.settings.notificationDisplay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAccount = (account: NotificationAccount) =>
|
||||||
|
run(`account-${account.id}`, async () => {
|
||||||
|
const next = preferences[account.id];
|
||||||
|
if (!next) return;
|
||||||
|
const saved = await wrap(
|
||||||
|
() => api.put<NotificationPreferences>(
|
||||||
|
`/admin/api/accounts/${encodeURIComponent(account.id)}/notifications`,
|
||||||
|
next,
|
||||||
|
),
|
||||||
|
`Notification settings saved for ${account.shortName || account.username}.`,
|
||||||
|
);
|
||||||
|
if (saved) {
|
||||||
|
setPreferences((current) => ({ ...current, [account.id]: saved }));
|
||||||
|
accounts.set({
|
||||||
|
accounts: (accounts.data?.accounts ?? []).map((entry) =>
|
||||||
|
entry.id === account.id ? { ...entry, notifications: saved } : entry,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = gateway.error || accounts.error;
|
||||||
|
const rows = accounts.data?.accounts ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="TV notifications"
|
||||||
|
intro="Choose where household notices may appear, then mute or allow them for each person."
|
||||||
|
actions={<Link className="btn" to="/admin/notifications">View notification history</Link>}
|
||||||
|
/>
|
||||||
|
<Banner message={error} />
|
||||||
|
|
||||||
|
{gateway.loading || display === null ? (
|
||||||
|
<Loading rows={2} />
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
title="Where notifications appear"
|
||||||
|
intro="This household-wide rule applies to every user and television. Maintenance and mandatory update screens still appear because they are not optional notifications."
|
||||||
|
icon="tv"
|
||||||
|
tone="info"
|
||||||
|
actions={<Tag tone={display === 'off' ? undefined : 'ok'}>{displayChoices.find((choice) => choice.value === display)?.label ?? 'Home only'}</Tag>}
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon="check"
|
||||||
|
busy={busy === 'display'}
|
||||||
|
onClick={() => void saveDisplay()}
|
||||||
|
>
|
||||||
|
Save display rule
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="checks columns">
|
||||||
|
{displayChoices.map((choice) => (
|
||||||
|
<label className="check" key={choice.value}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="notification-display"
|
||||||
|
checked={display === choice.value}
|
||||||
|
onChange={() => setDisplay(choice.value)}
|
||||||
|
/>
|
||||||
|
<span className="switch" aria-hidden="true" />
|
||||||
|
<span className="check-body">
|
||||||
|
<b>{choice.label}</b>
|
||||||
|
<p>{choice.description}</p>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="People"
|
||||||
|
intro="A person's master switch follows them to every television. Open their user page to choose individual notification types."
|
||||||
|
icon="people"
|
||||||
|
tone="note"
|
||||||
|
>
|
||||||
|
{accounts.loading ? (
|
||||||
|
<Loading />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="empty">No one has signed in to Memby yet.</p>
|
||||||
|
) : (
|
||||||
|
<Grid cols="2">
|
||||||
|
{rows.map((account) => {
|
||||||
|
const value = preferences[account.id] ?? account.notifications;
|
||||||
|
const name = account.shortName || account.username || 'Unnamed user';
|
||||||
|
const changed = value.enabled !== account.notifications.enabled;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={account.id}
|
||||||
|
title={name}
|
||||||
|
intro={account.shortName ? account.username : 'Memby user'}
|
||||||
|
icon="person"
|
||||||
|
tone="note"
|
||||||
|
actions={<Tag tone={value.enabled ? 'ok' : undefined}>{value.enabled ? 'enabled' : 'muted'}</Tag>}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
busy={busy === `account-${account.id}`}
|
||||||
|
disabled={!changed}
|
||||||
|
onClick={() => void saveAccount(account)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Link className="btn" to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
|
||||||
|
Choose notification types
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
label="All notifications"
|
||||||
|
hint="Turn off every optional notification for this person."
|
||||||
|
checked={value.enabled}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
setPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
[account.id]: { ...value, enabled },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -158,6 +158,7 @@ export function NotificationsPage() {
|
|||||||
<PageHead
|
<PageHead
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
intro="Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."
|
intro="Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."
|
||||||
|
actions={<Link className="btn" to="/admin/notification-settings">TV notification settings</Link>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Banner message={log.error} />
|
<Banner message={log.error} />
|
||||||
|
|||||||
@@ -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.06"
|
val defaultVersionName = "0.3.07"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class MaintenanceMonitor(
|
|||||||
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
||||||
|
|
||||||
private val _preferencesRevision = MutableStateFlow(0L)
|
private val _preferencesRevision = MutableStateFlow(0L)
|
||||||
|
private val _seriesStatusRevision = MutableStateFlow(0L)
|
||||||
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||||
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
|
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
|
||||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||||
@@ -136,6 +137,12 @@ class MaintenanceMonitor(
|
|||||||
*/
|
*/
|
||||||
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
|
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revision of the server's persisted Sonarr lifecycle catalogue. Detail metadata is
|
||||||
|
* process-cached, so a changed value tells the launcher to discard old series facts.
|
||||||
|
*/
|
||||||
|
val seriesStatusRevision: StateFlow<Long> = _seriesStatusRevision.asStateFlow()
|
||||||
|
|
||||||
/** One household-wide composition, delivered by the status poll to every viewer. */
|
/** One household-wide composition, delivered by the status poll to every viewer. */
|
||||||
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
|
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
|
||||||
val metadataHeroTimeRemainingColour: StateFlow<String> =
|
val metadataHeroTimeRemainingColour: StateFlow<String> =
|
||||||
@@ -288,6 +295,7 @@ class MaintenanceMonitor(
|
|||||||
// clearing it here would fight that loop for the same flow.
|
// clearing it here would fight that loop for the same flow.
|
||||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
|
_seriesStatusRevision.value = 0
|
||||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||||
_theme.value = GatewayThemeStatus()
|
_theme.value = GatewayThemeStatus()
|
||||||
@@ -327,6 +335,7 @@ class MaintenanceMonitor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
_preferencesRevision.value = status.preferencesRevision
|
_preferencesRevision.value = status.preferencesRevision
|
||||||
|
_seriesStatusRevision.value = status.seriesStatusRevision
|
||||||
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
|
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
|
||||||
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
||||||
.distinct()
|
.distinct()
|
||||||
@@ -382,6 +391,7 @@ class MaintenanceMonitor(
|
|||||||
_compatibility.value = null
|
_compatibility.value = null
|
||||||
_embyOutage.value = null
|
_embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
|
_seriesStatusRevision.value = 0
|
||||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||||
_theme.value = GatewayThemeStatus()
|
_theme.value = GatewayThemeStatus()
|
||||||
|
|||||||
@@ -416,9 +416,9 @@ data class BaseItem(
|
|||||||
@SerialName("SeriesId") val seriesId: String? = null,
|
@SerialName("SeriesId") val seriesId: String? = null,
|
||||||
@SerialName("SeriesName") val seriesName: String? = null,
|
@SerialName("SeriesName") val seriesName: String? = null,
|
||||||
/**
|
/**
|
||||||
* Emby's production status for a series — "Continuing" or "Ended". Only the detail
|
* Canonical production status for a series. In gateway mode the detail response
|
||||||
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
|
* replaces Emby's value with the latest stored Sonarr observation where one matches;
|
||||||
* absence as "not known to be running" rather than guessing either way.
|
* direct mode keeps Emby as the only source.
|
||||||
*/
|
*/
|
||||||
@SerialName("Status") val status: String? = null,
|
@SerialName("Status") val status: String? = null,
|
||||||
// Emby returns these on episodes without being asked, so they cost no extra Fields.
|
// Emby returns these on episodes without being asked, so they cost no extra Fields.
|
||||||
@@ -504,15 +504,15 @@ data class BaseItem(
|
|||||||
val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank()
|
val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
|
* Whether more episodes are expected. The canonical detail [status] wins; schedule
|
||||||
* one, since it knows about a season announced but not yet imported; Emby's own
|
* lifecycle metadata is only the opening-frame fallback before that record arrives.
|
||||||
* [status] is the fallback and the only source the direct path has. Neither present
|
* Neither present means no, which keeps the pace estimate saying "finish" — the weaker
|
||||||
* means no, which keeps the pace estimate saying "finish" — the weaker claim.
|
* claim.
|
||||||
*/
|
*/
|
||||||
val isOngoingSeries: Boolean
|
val isOngoingSeries: Boolean
|
||||||
get() = when {
|
get() = when {
|
||||||
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
|
status != null -> status.equals("Continuing", ignoreCase = true)
|
||||||
else -> status.equals("Continuing", ignoreCase = true)
|
else -> membyLifecycle.equals("continuing", ignoreCase = true)
|
||||||
}
|
}
|
||||||
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
|
|||||||
* on the poll the app is already making.
|
* on the poll the app is already making.
|
||||||
*/
|
*/
|
||||||
val preferencesRevision: Long = 0,
|
val preferencesRevision: Long = 0,
|
||||||
|
/** Monotonic revision of the gateway's stored Sonarr series lifecycle catalogue. */
|
||||||
|
val seriesStatusRevision: Long = 0,
|
||||||
/** Household-wide order of the focused metadata hero's content blocks. */
|
/** Household-wide order of the focused metadata hero's content blocks. */
|
||||||
val metadataHeroContentOrder: List<String> = emptyList(),
|
val metadataHeroContentOrder: List<String> = emptyList(),
|
||||||
/** Household-wide colour for the optional time-remaining caption: green or white. */
|
/** Household-wide colour for the optional time-remaining caption: green or white. */
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ internal data class DetailsReturn(
|
|||||||
internal fun FocusedDetailsOverlay(
|
internal fun FocusedDetailsOverlay(
|
||||||
homeViewModel: HomeViewModel,
|
homeViewModel: HomeViewModel,
|
||||||
selected: BaseItem,
|
selected: BaseItem,
|
||||||
|
seriesStatusRevision: Long = 0,
|
||||||
restorePosition: Boolean,
|
restorePosition: Boolean,
|
||||||
onPlay: (BaseItem) -> Unit,
|
onPlay: (BaseItem) -> Unit,
|
||||||
onPlayTrailer: (BaseItem) -> Unit,
|
onPlayTrailer: (BaseItem) -> Unit,
|
||||||
@@ -345,12 +346,20 @@ internal fun FocusedDetailsOverlay(
|
|||||||
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
|
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
|
||||||
// still consulted for live user state, but it cannot replace the full record with the
|
// still consulted for live user state, but it cannot replace the full record with the
|
||||||
// lightweight Search card when focus returns behind the overlay.
|
// lightweight Search card when focus returns behind the overlay.
|
||||||
var detailMetadata by remember(selected.id) {
|
var detailMetadata by remember(selected.id, seriesStatusRevision) {
|
||||||
mutableStateOf(homeViewModel.detailMetadataSnapshot(selected.id))
|
mutableStateOf(
|
||||||
|
homeViewModel.detailMetadataSnapshot(selected.id, seriesStatusRevision),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
LaunchedEffect(selected.id, selected.isRadarrOnly, selected.isSchedule) {
|
LaunchedEffect(
|
||||||
|
selected.id,
|
||||||
|
selected.isRadarrOnly,
|
||||||
|
selected.isSchedule,
|
||||||
|
seriesStatusRevision,
|
||||||
|
) {
|
||||||
if (!selected.isRadarrOnly && !selected.isSchedule) {
|
if (!selected.isRadarrOnly && !selected.isSchedule) {
|
||||||
homeViewModel.loadDetailMetadata(selected.id)?.let { detailMetadata = it }
|
homeViewModel.loadDetailMetadata(selected.id, seriesStatusRevision)
|
||||||
|
?.let { detailMetadata = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
|
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ internal fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||||
|
val seriesStatusRevision by
|
||||||
|
ServiceLocator.maintenance.seriesStatusRevision.collectAsStateWithLifecycle()
|
||||||
val metadataHeroContentOrder by
|
val metadataHeroContentOrder by
|
||||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||||
val metadataHeroTimeRemainingColour by
|
val metadataHeroTimeRemainingColour by
|
||||||
@@ -2193,6 +2195,7 @@ internal fun HomeScreen(
|
|||||||
FocusedDetailsOverlay(
|
FocusedDetailsOverlay(
|
||||||
homeViewModel = homeViewModel,
|
homeViewModel = homeViewModel,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
|
seriesStatusRevision = seriesStatusRevision,
|
||||||
restorePosition = restoreDetailPosition,
|
restorePosition = restoreDetailPosition,
|
||||||
airingNotice = detailsAiringNotice,
|
airingNotice = detailsAiringNotice,
|
||||||
onOpenItem = { related ->
|
onOpenItem = { related ->
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
|
||||||
}
|
}
|
||||||
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
|
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
|
||||||
|
private var seriesStatusRevision = 0L
|
||||||
|
|
||||||
/** Row engagement, buffered here and uploaded in batches. */
|
/** Row engagement, buffered here and uploaded in batches. */
|
||||||
private val analytics = RowAnalytics()
|
private val analytics = RowAnalytics()
|
||||||
@@ -637,8 +638,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
* into it. A detail overlay uses this for its opening frame, then [loadDetailMetadata]
|
* into it. A detail overlay uses this for its opening frame, then [loadDetailMetadata]
|
||||||
* joins an existing request or starts the missing one.
|
* joins an existing request or starts the missing one.
|
||||||
*/
|
*/
|
||||||
internal fun detailMetadataSnapshot(itemId: String): BaseItem? =
|
internal fun detailMetadataSnapshot(itemId: String, statusRevision: Long = 0): BaseItem? =
|
||||||
synchronized(metadataCache) { metadataCache[itemId] }
|
synchronized(metadataCache) {
|
||||||
|
acceptSeriesStatusRevision(statusRevision)
|
||||||
|
metadataCache[itemId]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads one full item record, shared by focus prefetch and the detail overlay.
|
* Loads one full item record, shared by focus prefetch and the detail overlay.
|
||||||
@@ -649,10 +653,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
* reopened detail page relies on. The request lives in [viewModelScope], so losing
|
* reopened detail page relies on. The request lives in [viewModelScope], so losing
|
||||||
* focus or closing the first page does not cancel work a reopened page is awaiting.
|
* focus or closing the first page does not cancel work a reopened page is awaiting.
|
||||||
*/
|
*/
|
||||||
internal suspend fun loadDetailMetadata(itemId: String): BaseItem? {
|
internal suspend fun loadDetailMetadata(itemId: String, statusRevision: Long = 0): BaseItem? {
|
||||||
if (itemId.isBlank()) return null
|
if (itemId.isBlank()) return null
|
||||||
var cached: BaseItem? = null
|
var cached: BaseItem? = null
|
||||||
val request = synchronized(metadataCache) {
|
val request = synchronized(metadataCache) {
|
||||||
|
acceptSeriesStatusRevision(statusRevision)
|
||||||
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
|
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
null
|
null
|
||||||
@@ -663,8 +668,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
return cached ?: request?.await()
|
return cached ?: request?.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Must be called while synchronised on [metadataCache]. */
|
||||||
|
private fun acceptSeriesStatusRevision(revision: Long) {
|
||||||
|
if (revision <= 0L || revision == seriesStatusRevision) return
|
||||||
|
seriesStatusRevision = revision
|
||||||
|
metadataInFlight.values.forEach { it.cancel() }
|
||||||
|
metadataInFlight.clear()
|
||||||
|
metadataCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
private fun newDetailMetadataRequest(itemId: String): Deferred<BaseItem?> {
|
private fun newDetailMetadataRequest(itemId: String): Deferred<BaseItem?> {
|
||||||
val request = viewModelScope.async(
|
val revisionAtStart = seriesStatusRevision
|
||||||
|
lateinit var request: Deferred<BaseItem?>
|
||||||
|
request = viewModelScope.async(
|
||||||
context = Dispatchers.IO,
|
context = Dispatchers.IO,
|
||||||
start = CoroutineStart.LAZY,
|
start = CoroutineStart.LAZY,
|
||||||
) {
|
) {
|
||||||
@@ -672,10 +688,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
runCatching { repository.getItemDetails(itemId) }
|
runCatching { repository.getItemDetails(itemId) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
?.also { details ->
|
?.also { details ->
|
||||||
synchronized(metadataCache) { metadataCache[itemId] = details }
|
synchronized(metadataCache) {
|
||||||
|
if (seriesStatusRevision == revisionAtStart) {
|
||||||
|
metadataCache[itemId] = details
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
synchronized(metadataCache) { metadataInFlight.remove(itemId) }
|
synchronized(metadataCache) {
|
||||||
|
if (metadataInFlight[itemId] === request) {
|
||||||
|
metadataInFlight.remove(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadataInFlight[itemId] = request
|
metadataInFlight[itemId] = request
|
||||||
|
|||||||
@@ -385,14 +385,13 @@ internal fun SeriesDetailContent(
|
|||||||
// lists, and a new identity for them on every focus move recomposes the fact row.
|
// lists, and a new identity for them on every focus move recomposes the fact row.
|
||||||
val heroFacts = remember(
|
val heroFacts = remember(
|
||||||
item.id,
|
item.id,
|
||||||
seasons.size,
|
|
||||||
item.productionYear,
|
item.productionYear,
|
||||||
item.officialRating,
|
item.runTimeTicks,
|
||||||
item.status,
|
item.status,
|
||||||
item.membyLifecycle,
|
item.membyLifecycle,
|
||||||
item.membyLifecycleText,
|
item.membyLifecycleText,
|
||||||
) {
|
) {
|
||||||
heroFacts(item, seasons.size)
|
heroFacts(item)
|
||||||
}
|
}
|
||||||
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
|
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
|
||||||
|
|
||||||
|
|||||||
@@ -64,21 +64,18 @@ fun remainingLabel(item: BaseItem): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The quiet line directly under the title: year, length, certificate and, for a series,
|
* The quiet line directly under the title. A series uses the stable three-part contract
|
||||||
* its production status. The score sits beside the title and the genres are a credit row,
|
* year, episode runtime and production status; other media keeps its certificate there.
|
||||||
* so non-series titles stay at three items and never have to compete for the width.
|
* The score sits beside the title and the genres are a credit row.
|
||||||
*
|
|
||||||
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
|
|
||||||
*/
|
*/
|
||||||
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
fun heroFacts(item: BaseItem): List<String> = buildList {
|
||||||
item.productionYear?.let { add(it.toString()) }
|
item.productionYear?.let { add(it.toString()) }
|
||||||
if (seasonCount > 0) {
|
|
||||||
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
|
||||||
} else {
|
|
||||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||||
}
|
if (item.isSeries) {
|
||||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
|
||||||
seriesStatusLabel(item)?.let(::add)
|
seriesStatusLabel(item)?.let(::add)
|
||||||
|
} else {
|
||||||
|
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,14 +87,15 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
|||||||
*/
|
*/
|
||||||
fun seriesStatusLabel(item: BaseItem): String? {
|
fun seriesStatusLabel(item: BaseItem): String? {
|
||||||
if (!item.isSeries) return null
|
if (!item.isSeries) return null
|
||||||
val status = item.membyLifecycleText?.takeIf(String::isNotBlank)
|
val status = item.status?.takeIf(String::isNotBlank)
|
||||||
|
?: item.membyLifecycleText?.takeIf(String::isNotBlank)
|
||||||
?: item.membyLifecycle?.takeIf(String::isNotBlank)
|
?: item.membyLifecycle?.takeIf(String::isNotBlank)
|
||||||
?: item.status?.takeIf(String::isNotBlank)
|
|
||||||
?: return null
|
?: return null
|
||||||
return when (status.trim().lowercase(Locale.US)) {
|
return when (status.trim().lowercase(Locale.US)) {
|
||||||
"continuing" -> "Continuing"
|
"continuing" -> "Continuing"
|
||||||
"ended" -> "Ended"
|
"ended" -> "Ended"
|
||||||
"cancelled", "canceled" -> "Cancelled"
|
"cancelled", "canceled" -> "Cancelled"
|
||||||
|
"upcoming" -> "Upcoming"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.player
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.ponzischeme89.memby.R
|
||||||
|
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||||
|
import com.ponzischeme89.memby.ui.cinematicBackdropPullBack
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||||
|
|
||||||
|
/** The already-resolved metadata needed to render a pause without doing repository work. */
|
||||||
|
internal data class PauseMediaHeroMetadata(
|
||||||
|
val title: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val episodeCode: String? = null,
|
||||||
|
val overview: String = "",
|
||||||
|
val logoUrl: String? = null,
|
||||||
|
val backdropUrl: String? = null,
|
||||||
|
val primaryArtworkUrl: String? = null,
|
||||||
|
) {
|
||||||
|
val isEpisode: Boolean get() = !episodeCode.isNullOrBlank() || !seriesName.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memby's paused-playback identity surface.
|
||||||
|
*
|
||||||
|
* The player owns only [visible] and immutable metadata. This component owns every visual
|
||||||
|
* layer, remains non-interactive, and therefore cannot enter or alter the transport's TV
|
||||||
|
* focus graph.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun PauseMediaHero(
|
||||||
|
metadata: PauseMediaHeroMetadata,
|
||||||
|
visible: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = fadeIn(tween(PauseHeroEnterMs)) +
|
||||||
|
slideInVertically(tween(PauseHeroEnterMs)) { height -> height / 40 },
|
||||||
|
exit = fadeOut(tween(PauseHeroExitMs)) +
|
||||||
|
slideOutVertically(tween(PauseHeroExitMs)) { height -> height / 60 },
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
PauseHeroBackdrop(metadata.backdropUrl)
|
||||||
|
PauseHeroContent(
|
||||||
|
metadata = metadata,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(start = 48.dp, top = 34.dp, end = 48.dp, bottom = 214.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PauseHeroBackdrop(backdropUrl: String?) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val request = remember(backdropUrl, context) {
|
||||||
|
backdropUrl?.let {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(it)
|
||||||
|
.size(1280, 720)
|
||||||
|
.allowHardware(true)
|
||||||
|
.crossfade(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.74f))) {
|
||||||
|
if (request != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = request,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
alignment = Alignment.CenterEnd,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.fillMaxWidth(0.74f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.cinematicBackdropPullBack(backdropUrl),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// These are the same layered directions as the Home Metadata Hero: a firm text
|
||||||
|
// field at the start, a controlled reveal through the artwork, and a dark landing
|
||||||
|
// beneath the transport. The frozen video remains present only as a subdued base.
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
0f to MembySurface.copy(alpha = 0.98f),
|
||||||
|
0.34f to MembySurface.copy(alpha = 0.92f),
|
||||||
|
0.56f to MembySurface.copy(alpha = 0.56f),
|
||||||
|
0.78f to Color.Black.copy(alpha = 0.28f),
|
||||||
|
1f to Color.Black.copy(alpha = 0.18f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to Color.Black.copy(alpha = 0.24f),
|
||||||
|
0.55f to Color.Transparent,
|
||||||
|
0.78f to Color.Black.copy(alpha = 0.34f),
|
||||||
|
1f to Color.Black.copy(alpha = 0.88f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PauseHeroContent(
|
||||||
|
metadata: PauseMediaHeroMetadata,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.Top) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.player_paused),
|
||||||
|
color = MembyAccentBright,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 0.18.em,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
PauseHeroTitle(metadata)
|
||||||
|
if (metadata.isEpisode) {
|
||||||
|
pauseHeroEpisodeLabel(metadata)?.let { label ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = MembyOnSurface,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
lineHeight = 23.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
PauseHeroSummary(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PauseHeroTitle(metadata: PauseMediaHeroMetadata) {
|
||||||
|
val heading = metadata.seriesName?.takeIf { metadata.isEpisode && it.isNotBlank() }
|
||||||
|
?: metadata.title.takeIf(String::isNotBlank)
|
||||||
|
?: stringResource(R.string.player_now_playing)
|
||||||
|
val logo = metadata.logoUrl?.takeIf { !useTextTitleForLogo(it) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.52f).heightIn(max = 76.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (logo != null) {
|
||||||
|
TitleLogoImage(
|
||||||
|
logoUrl = logo,
|
||||||
|
contentDescription = heading,
|
||||||
|
alignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier.width(340.dp).height(76.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = heading,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 34.sp,
|
||||||
|
lineHeight = 38.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PauseHeroSummary(metadata: PauseMediaHeroMetadata) {
|
||||||
|
val synopsis = metadata.overview.ifBlank {
|
||||||
|
stringResource(R.string.player_pause_overview_fallback)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.64f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
metadata.primaryArtworkUrl?.let { artwork ->
|
||||||
|
AsyncImage(
|
||||||
|
model = artwork,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = if (metadata.isEpisode) {
|
||||||
|
Modifier.width(218.dp).aspectRatio(16f / 9f)
|
||||||
|
.clip(RoundedCornerShape(MembyCardCorner))
|
||||||
|
.background(MembySurface)
|
||||||
|
} else {
|
||||||
|
Modifier.size(width = 116.dp, height = 174.dp)
|
||||||
|
.clip(RoundedCornerShape(MembyCardCorner))
|
||||||
|
.background(MembySurface)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = synopsis,
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
maxLines = if (metadata.isEpisode) 5 else 7,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.player_pause_resume_hint),
|
||||||
|
color = MembyOnSurface.copy(alpha = 0.76f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun pauseHeroEpisodeLabel(metadata: PauseMediaHeroMetadata): String? {
|
||||||
|
if (!metadata.isEpisode) return null
|
||||||
|
val code = metadata.episodeCode?.trim().orEmpty()
|
||||||
|
val spacedCode = Regex("^S(\\d{1,2})E(\\d{1,3})$", RegexOption.IGNORE_CASE)
|
||||||
|
.matchEntire(code)
|
||||||
|
?.destructured
|
||||||
|
?.let { (season, episode) -> "S${season.padStart(2, '0')} E${episode.padStart(2, '0')}" }
|
||||||
|
?: code.takeIf(String::isNotBlank)
|
||||||
|
val episodeTitle = pauseHeroEpisodeTitle(metadata.title, metadata.seriesName)
|
||||||
|
return listOfNotNull(spacedCode, episodeTitle).joinToString(" • ").takeIf(String::isNotBlank)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pauseHeroEpisodeTitle(title: String, seriesName: String?): String? {
|
||||||
|
var episodeTitle = title.trim()
|
||||||
|
val series = seriesName?.trim().orEmpty()
|
||||||
|
if (series.isNotEmpty()) {
|
||||||
|
listOf(" – ", " — ", " - ").firstOrNull { separator ->
|
||||||
|
episodeTitle.startsWith(series + separator, ignoreCase = true)
|
||||||
|
}?.let { separator -> episodeTitle = episodeTitle.drop(series.length + separator.length).trim() }
|
||||||
|
}
|
||||||
|
return episodeTitle.takeIf { it.isNotBlank() && !it.equals(series, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PauseHeroEnterMs = 180
|
||||||
|
private const val PauseHeroExitMs = 120
|
||||||
@@ -29,8 +29,10 @@ import android.widget.TextView
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ComposeView
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -243,6 +245,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
||||||
private var pausePosterUrl: String? = null
|
private var pausePosterUrl: String? = null
|
||||||
private var pauseOverlay: View? = null
|
private var pauseOverlay: View? = null
|
||||||
|
private val pauseHeroVisible = mutableStateOf(false)
|
||||||
|
private val pauseHeroMetadata = mutableStateOf(PauseMediaHeroMetadata())
|
||||||
private var nowPlayingGroup: View? = null
|
private var nowPlayingGroup: View? = null
|
||||||
private var playbackIdentityView: View? = null
|
private var playbackIdentityView: View? = null
|
||||||
private var playbackIdentityHideJob: Job? = null
|
private var playbackIdentityHideJob: Job? = null
|
||||||
@@ -1335,7 +1339,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
episodeCode = prerollEpisodeCode,
|
episodeCode = prerollEpisodeCode,
|
||||||
logoUrl = logoUrl,
|
logoUrl = logoUrl,
|
||||||
)
|
)
|
||||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
updatePauseHeroMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPreroll() {
|
private fun startPreroll() {
|
||||||
@@ -2358,7 +2362,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun showPlaybackIdentity() {
|
private fun showPlaybackIdentity() {
|
||||||
val identity = playbackIdentityView ?: return
|
val identity = playbackIdentityView ?: return
|
||||||
val paused = pauseOverlay?.visibility == View.VISIBLE
|
val paused = pauseHeroVisible.value
|
||||||
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
|
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
|
||||||
MembyDiagnostics.debug(
|
MembyDiagnostics.debug(
|
||||||
"station_ident_withheld",
|
"station_ident_withheld",
|
||||||
@@ -2440,7 +2444,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val slot = playerIdentitySlot(
|
val slot = playerIdentitySlot(
|
||||||
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
|
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
|
||||||
transportVisible = transportVisible,
|
transportVisible = transportVisible,
|
||||||
paused = pauseOverlay?.visibility == View.VISIBLE,
|
paused = pauseHeroVisible.value,
|
||||||
)
|
)
|
||||||
nowPlayingGroup?.visibility =
|
nowPlayingGroup?.visibility =
|
||||||
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
|
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
|
||||||
@@ -4328,19 +4332,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
episodeCode = prerollEpisodeCode,
|
episodeCode = prerollEpisodeCode,
|
||||||
logoUrl = logoUrl,
|
logoUrl = logoUrl,
|
||||||
)
|
)
|
||||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
updatePauseHeroMetadata()
|
||||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
|
|
||||||
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
|
|
||||||
pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
|
|
||||||
val poster = pausePosterUrl
|
|
||||||
if (poster.isNullOrBlank()) {
|
|
||||||
visibility = View.GONE
|
|
||||||
setImageDrawable(null)
|
|
||||||
} else {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
load(poster) { crossfade(true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hidePlaybackError()
|
hidePlaybackError()
|
||||||
showPlaybackLoading()
|
showPlaybackLoading()
|
||||||
if (playback != null) {
|
if (playback != null) {
|
||||||
@@ -4378,30 +4370,47 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun bindPauseOverlay(view: PlayerView) {
|
private fun bindPauseOverlay(view: PlayerView) {
|
||||||
pauseOverlay = view.findViewById(R.id.player_pause_overlay)
|
pauseOverlay = view.findViewById<ComposeView>(R.id.player_pause_overlay).apply {
|
||||||
|
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||||
|
setContent {
|
||||||
|
val visible by pauseHeroVisible
|
||||||
|
val metadata by pauseHeroMetadata
|
||||||
|
MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
|
||||||
|
PauseMediaHero(
|
||||||
|
metadata = metadata,
|
||||||
|
visible = visible,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
|
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
|
||||||
// The group is visible in the layout, so state it here too: nothing else runs before
|
// The group is visible in the layout, so state it here too: nothing else runs before
|
||||||
// the transport is first raised, and the ident's five seconds are inside that window.
|
// the transport is first raised, and the ident's five seconds are inside that window.
|
||||||
applyIdentityRegion()
|
applyIdentityRegion()
|
||||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
updatePauseHeroMetadata()
|
||||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
|
|
||||||
text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
|
|
||||||
}
|
|
||||||
pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
|
|
||||||
val poster = pausePosterUrl
|
|
||||||
if (poster.isNullOrBlank()) {
|
|
||||||
visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
load(poster) { crossfade(true) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updatePauseHeroMetadata() {
|
||||||
|
pauseHeroMetadata.value = PauseMediaHeroMetadata(
|
||||||
|
title = playbackTitle,
|
||||||
|
seriesName = playbackSeriesName,
|
||||||
|
episodeCode = prerollEpisodeCode,
|
||||||
|
overview = pauseOverview,
|
||||||
|
logoUrl = logoUrl,
|
||||||
|
backdropUrl = loadingBackdropUrl,
|
||||||
|
primaryArtworkUrl = pausePosterUrl,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePauseOverlay(playback: Player) {
|
private fun updatePauseOverlay(playback: Player) {
|
||||||
val paused = playbackStarted && !prerollActive &&
|
val paused = playbackStarted && !prerollActive &&
|
||||||
playback.playbackState == Player.STATE_READY && !playback.isPlaying
|
playback.playbackState == Player.STATE_READY && !playback.isPlaying
|
||||||
pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE
|
// Metadata can change without rebuilding the activity (next episode, preview,
|
||||||
|
// refreshed stream). Bind it at the state edge, before the enter transition starts,
|
||||||
|
// so pausing never shows the outgoing programme for a frame.
|
||||||
|
if (paused) updatePauseHeroMetadata()
|
||||||
|
pauseHeroVisible.value = paused
|
||||||
// Pausing during the ident hands the corner to the pause overlay, which carries the
|
// Pausing during the ident hands the corner to the pause overlay, which carries the
|
||||||
// poster, the title and the synopsis: an ident over the top of that is the same
|
// poster, the title and the synopsis: an ident over the top of that is the same
|
||||||
// programme announced twice, in two type sizes, in overlapping space.
|
// programme announced twice, in two type sizes, in overlapping space.
|
||||||
|
|||||||
@@ -1,80 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:id="@+id/player_pause_overlay"
|
android:id="@+id/player_pause_overlay"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
android:visibility="gone">
|
android:importantForAccessibility="no"
|
||||||
|
android:visibility="visible" />
|
||||||
<View
|
|
||||||
android:layout_width="820dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="@drawable/player_pause_scrim" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="760dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="start|center_vertical"
|
|
||||||
android:layout_marginStart="48dp"
|
|
||||||
android:layout_marginBottom="62dp"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/player_pause_poster"
|
|
||||||
android:layout_width="164dp"
|
|
||||||
android:layout_height="246dp"
|
|
||||||
android:background="@drawable/player_pause_poster_background"
|
|
||||||
android:clipToOutline="true"
|
|
||||||
android:contentDescription="@string/player_pause_poster"
|
|
||||||
android:outlineProvider="background"
|
|
||||||
android:scaleType="centerCrop" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="30dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:letterSpacing="0.18"
|
|
||||||
android:text="@string/player_paused"
|
|
||||||
android:textColor="#FF6BCB63"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/player_pause_title"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:maxLines="2"
|
|
||||||
android:textColor="#FFFFFFFF"
|
|
||||||
android:textSize="34sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/player_pause_overview"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="14dp"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:lineSpacingExtra="3dp"
|
|
||||||
android:maxLines="5"
|
|
||||||
android:textColor="#DDE7EBEE"
|
|
||||||
android:textSize="16sp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="18dp"
|
|
||||||
android:text="@string/player_pause_resume_hint"
|
|
||||||
android:textColor="#B8FFFFFF"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|||||||
@@ -197,10 +197,11 @@ class GatewayPayloadTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `decodes the settings revision an operator push arrives as`() {
|
fun `decodes the settings revision an operator push arrives as`() {
|
||||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||||
"""{"maintenance":false,"preferencesRevision":12}""",
|
"""{"maintenance":false,"preferencesRevision":12,"seriesStatusRevision":34}""",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(12L, status.preferencesRevision)
|
assertEquals(12L, status.preferencesRevision)
|
||||||
|
assertEquals(34L, status.seriesStatusRevision)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -100,13 +100,14 @@ class SeriesDetailsTest {
|
|||||||
name = "Series",
|
name = "Series",
|
||||||
type = "Series",
|
type = "Series",
|
||||||
productionYear = 2022,
|
productionYear = 2022,
|
||||||
|
runTimeTicks = 31_200_000_000L,
|
||||||
officialRating = "TV-MA",
|
officialRating = "TV-MA",
|
||||||
status = "Continuing",
|
status = "Continuing",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("2022", "4 Seasons", "TV-MA", "Continuing"),
|
listOf("2022", "52m", "Continuing"),
|
||||||
heroFacts(series, seasonCount = 4),
|
heroFacts(series),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +121,8 @@ class SeriesDetailsTest {
|
|||||||
membyLifecycleText = "CANCELED",
|
membyLifecycleText = "CANCELED",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("Cancelled", heroFacts(series).last())
|
assertEquals("Ended", heroFacts(series).last())
|
||||||
assertEquals("Ended", heroFacts(series.copy(membyLifecycleText = null)).last())
|
assertEquals("Cancelled", heroFacts(series.copy(status = null)).last())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -141,7 +142,7 @@ class SeriesDetailsTest {
|
|||||||
status = "Continuing",
|
status = "Continuing",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("2022"), heroFacts(unknownSeries))
|
assertEquals(listOf("2022", "Upcoming"), heroFacts(unknownSeries))
|
||||||
assertEquals(listOf("2022"), heroFacts(movie))
|
assertEquals(listOf("2022"), heroFacts(movie))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.player
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PauseMediaHeroTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeLabelSeparatesSeasonEpisodeAndTitle() {
|
||||||
|
assertEquals(
|
||||||
|
"S04 E06 • The Dive",
|
||||||
|
pauseHeroEpisodeLabel(
|
||||||
|
PauseMediaHeroMetadata(
|
||||||
|
title = "Northbound – The Dive",
|
||||||
|
seriesName = "Northbound",
|
||||||
|
episodeCode = "S04E06",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeLabelDoesNotRepeatSeriesName() {
|
||||||
|
assertEquals(
|
||||||
|
"S01 E02",
|
||||||
|
pauseHeroEpisodeLabel(
|
||||||
|
PauseMediaHeroMetadata(
|
||||||
|
title = "Harbour Lights",
|
||||||
|
seriesName = "Harbour Lights",
|
||||||
|
episodeCode = "S01E02",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movieHasNoEpisodeLabel() {
|
||||||
|
assertNull(pauseHeroEpisodeLabel(PauseMediaHeroMetadata(title = "Uproar")))
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-15
@@ -7,8 +7,18 @@ import android.view.View
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||||
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.Robolectric
|
import org.robolectric.Robolectric
|
||||||
@@ -20,23 +30,43 @@ import org.robolectric.annotation.GraphicsMode
|
|||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||||
class PlayerPauseOverlayScreenshotTest {
|
class PlayerPauseOverlayScreenshotTest {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `paused movie shows focused poster synopsis and resume controls`() {
|
fun `paused episode shows darkened hero artwork and synopsis`() {
|
||||||
val (activity, root, controls) = playerSurface()
|
val preview = requireNotNull(previewArtwork())
|
||||||
|
val artworkUrl = requireNotNull(javaClass.classLoader?.getResource("home_hero_preview_art.png"))
|
||||||
|
.toExternalForm()
|
||||||
|
compose.setContent {
|
||||||
|
MembyTheme {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Image(
|
||||||
|
bitmap = preview.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
PauseMediaHero(
|
||||||
|
metadata = PauseMediaHeroMetadata(
|
||||||
|
title = "Northbound – The Last Horizon",
|
||||||
|
seriesName = "Northbound",
|
||||||
|
episodeCode = "S04E06",
|
||||||
|
overview = "A cartographer follows a signal beyond the edge of the known world, " +
|
||||||
|
"where an abandoned observatory may hold the way home.",
|
||||||
|
backdropUrl = artworkUrl,
|
||||||
|
primaryArtworkUrl = artworkUrl,
|
||||||
|
),
|
||||||
|
visible = true,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.VISIBLE
|
compose.onRoot().captureRoboImage(
|
||||||
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
|
"build/screenshots/player-pause-overlay/player-paused-episode-hero.png",
|
||||||
controls.findViewById<TextView>(R.id.player_pause_title).text = "The Last Horizon"
|
)
|
||||||
controls.findViewById<TextView>(R.id.player_pause_overview).text =
|
|
||||||
"A cartographer follows a signal beyond the edge of the known world, " +
|
|
||||||
"where an abandoned observatory may hold the way home."
|
|
||||||
controls.findViewById<ImageView>(R.id.player_pause_poster).setImageBitmap(previewArtwork())
|
|
||||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
|
|
||||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
|
|
||||||
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
|
|
||||||
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
|
|
||||||
|
|
||||||
root.captureRoboImage("build/screenshots/player-pause-overlay/player-paused-movie-overlay.png")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -362,6 +362,9 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
|||||||
if _, ok := body["preferencesRevision"]; !ok {
|
if _, ok := body["preferencesRevision"]; !ok {
|
||||||
t.Fatalf("status response carried no preferences revision: %v", body)
|
t.Fatalf("status response carried no preferences revision: %v", body)
|
||||||
}
|
}
|
||||||
|
if _, ok := body["seriesStatusRevision"]; !ok {
|
||||||
|
t.Fatalf("status response carried no series status revision: %v", body)
|
||||||
|
}
|
||||||
metadataOrder, ok := body["metadataHeroContentOrder"].([]any)
|
metadataOrder, ok := body["metadataHeroContentOrder"].([]any)
|
||||||
if !ok || len(metadataOrder) == 0 {
|
if !ok || len(metadataOrder) == 0 {
|
||||||
t.Fatalf("status response carried no global metadata hero order: %v", body)
|
t.Fatalf("status response carried no global metadata hero order: %v", body)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const (
|
|||||||
// tab's vocabulary and are read nowhere else. They belong here for the same reason: this
|
// tab's vocabulary and are read nowhere else. They belong here for the same reason: this
|
||||||
// call is made once, after D-pad focus has settled on a card, and every one of them
|
// call is made once, after D-pad focus has settled on a card, and every one of them
|
||||||
// would be a per-card cost on a home row.
|
// would be a per-card cost on a home row.
|
||||||
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
|
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ProviderIds,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
|
||||||
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
||||||
|
|
||||||
rowImageTypes = "Backdrop,Primary,Logo"
|
rowImageTypes = "Backdrop,Primary,Logo"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
writeError(w, http.StatusBadRequest, "item id is required")
|
writeError(w, http.StatusBadRequest, "item id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if raw, err := s.cache.Get(ctx, itemDetailKey(viewerKeyOf(ctx, sess), itemID)); err == nil {
|
if raw, err := s.cache.Get(ctx, s.itemDetailKey(ctx, viewerKeyOf(ctx, sess), itemID)); err == nil {
|
||||||
w.Header().Set("X-Memby-Cache", "hit")
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
writeRaw(w, http.StatusOK, raw)
|
writeRaw(w, http.StatusOK, raw)
|
||||||
return
|
return
|
||||||
@@ -65,8 +65,16 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
|
|
||||||
// itemDetailKey is versioned so that when the detail contract grows, older cached payloads
|
// itemDetailKey is versioned so that when the detail contract grows, older cached payloads
|
||||||
// cannot hide newly requested fields such as People or the stored ratings.
|
// cannot hide newly requested fields such as People or the stored ratings.
|
||||||
func itemDetailKey(userID, itemID string) string {
|
func (s *Server) itemDetailKey(ctx context.Context, userID, itemID string) string {
|
||||||
return cache.UserKey(userID, "item:v6:"+itemID)
|
revision := int64(0)
|
||||||
|
if s.store != nil {
|
||||||
|
if stored, err := s.store.SonarrSeriesStatusRevision(ctx); err == nil {
|
||||||
|
revision = stored
|
||||||
|
} else {
|
||||||
|
s.loggerFor(ctx).Warn("series status revision unavailable for item cache", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cache.UserKey(userID, "item:v7:r"+strconv.FormatInt(revision, 10)+":"+itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailItem is the full record for one item, decorated and kept.
|
// detailItem is the full record for one item, decorated and kept.
|
||||||
@@ -77,7 +85,7 @@ func itemDetailKey(userID, itemID string) string {
|
|||||||
func (s *Server) detailItem(
|
func (s *Server) detailItem(
|
||||||
ctx context.Context, sess store.Session, itemID string,
|
ctx context.Context, sess store.Session, itemID string,
|
||||||
) (json.RawMessage, error) {
|
) (json.RawMessage, error) {
|
||||||
item, _, err := s.cachedRead(ctx, itemDetailKey(sess.EmbyUserID, itemID), s.cfg.ItemTTL,
|
item, _, err := s.cachedRead(ctx, s.itemDetailKey(ctx, sess.EmbyUserID, itemID), s.cfg.ItemTTL,
|
||||||
func(ctx context.Context) (json.RawMessage, error) {
|
func(ctx context.Context) (json.RawMessage, error) {
|
||||||
raw, err := s.emby.Item(
|
raw, err := s.emby.Item(
|
||||||
timing.WithLabel(ctx, "emby.item"), credentials(sess), itemID, fieldsDetail)
|
timing.WithLabel(ctx, "emby.item"), credentials(sess), itemID, fieldsDetail)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func seriesLifecycleTag(status string) lifecycleTag {
|
|||||||
return lifecycleTag{Status: "upcoming", Label: "UPCOMING"}
|
return lifecycleTag{Status: "upcoming", Label: "UPCOMING"}
|
||||||
case "ended":
|
case "ended":
|
||||||
return lifecycleTag{Status: "ended", Label: "ENDED"}
|
return lifecycleTag{Status: "ended", Label: "ENDED"}
|
||||||
|
case "cancelled", "canceled":
|
||||||
|
return lifecycleTag{Status: "cancelled", Label: "CANCELLED"}
|
||||||
case "deleted":
|
case "deleted":
|
||||||
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
|
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ func TestSeriesLifecycleTag(t *testing.T) {
|
|||||||
"Continuing": {Status: "continuing", Label: "CONTINUING"},
|
"Continuing": {Status: "continuing", Label: "CONTINUING"},
|
||||||
"upcoming": {Status: "upcoming", Label: "UPCOMING"},
|
"upcoming": {Status: "upcoming", Label: "UPCOMING"},
|
||||||
"ended": {Status: "ended", Label: "ENDED"},
|
"ended": {Status: "ended", Label: "ENDED"},
|
||||||
|
"cancelled": {Status: "cancelled", Label: "CANCELLED"},
|
||||||
|
"canceled": {Status: "cancelled", Label: "CANCELLED"},
|
||||||
"deleted": {Status: "deleted", Label: "REMOVED"},
|
"deleted": {Status: "deleted", Label: "REMOVED"},
|
||||||
// No tag at all rather than an invented one.
|
// No tag at all rather than an invented one.
|
||||||
"": {},
|
"": {},
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
|||||||
compatible, compatibilityMessage := compatibilityFor(r)
|
compatible, compatibilityMessage := compatibilityFor(r)
|
||||||
featurePolicy := s.currentFeaturePolicy(r.Context())
|
featurePolicy := s.currentFeaturePolicy(r.Context())
|
||||||
metadataHero := s.metadataHeroSettings.get()
|
metadataHero := s.metadataHeroSettings.get()
|
||||||
|
seriesStatusRevision := int64(0)
|
||||||
|
if s.store != nil {
|
||||||
|
if revision, err := s.store.SonarrSeriesStatusRevision(r.Context()); err != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("series status revision unavailable", "error", err)
|
||||||
|
} else {
|
||||||
|
seriesStatusRevision = revision
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"maintenance": state.Enabled,
|
"maintenance": state.Enabled,
|
||||||
"quietTime": quiet.Active,
|
"quietTime": quiet.Active,
|
||||||
@@ -156,6 +164,9 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
|||||||
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
||||||
// delivery channel for an operator pushing someone's settings.
|
// delivery channel for an operator pushing someone's settings.
|
||||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
||||||
|
// The TV keeps full item details in memory. This revision lets a daily Sonarr
|
||||||
|
// transition evict that local copy before the same show is opened again.
|
||||||
|
"seriesStatusRevision": seriesStatusRevision,
|
||||||
// The small metadata hero presentation document rides the existing poll so a saved
|
// The small metadata hero presentation document rides the existing poll so a saved
|
||||||
// layout or colour reaches an open launcher immediately, without manufacturing a
|
// layout or colour reaches an open launcher immediately, without manufacturing a
|
||||||
// per-user preference revision for a global change.
|
// per-user preference revision for a global change.
|
||||||
|
|||||||
@@ -236,11 +236,12 @@ func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
|
|||||||
|
|
||||||
// decorateItems is the one door items leave the gateway through.
|
// decorateItems is the one door items leave the gateway through.
|
||||||
//
|
//
|
||||||
// It attaches both of the things Memby knows about a title that Emby's payload does not
|
// It attaches the facts Memby knows about a title beyond Emby's payload: the latest stored
|
||||||
// carry: the stored review scores, and — for a shadow viewer — whose progress this is. The
|
// Sonarr series status, stored review scores, and — for a shadow viewer — whose progress
|
||||||
// two are separate concerns and stayed separate functions, but every call site wanted both,
|
// this is. The concerns stay in separate functions, but every call site needs one door;
|
||||||
// and a decoration added at seven sites is a decoration missing from the eighth.
|
// a decoration added at seven sites is a decoration missing from the eighth.
|
||||||
func (s *Server) decorateItems(ctx context.Context, collections ...[]json.RawMessage) {
|
func (s *Server) decorateItems(ctx context.Context, collections ...[]json.RawMessage) {
|
||||||
|
s.decorateSeriesStatuses(ctx, collections...)
|
||||||
s.decorateItemRatings(ctx, collections...)
|
s.decorateItemRatings(ctx, collections...)
|
||||||
s.decorateViewerState(ctx, collections...)
|
s.decorateViewerState(ctx, collections...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decorateSeriesStatuses makes Status on an ordinary Emby series the canonical detail
|
||||||
|
// answer. Sonarr's daily observation wins when it is recognised; otherwise Emby's field
|
||||||
|
// is left untouched as the fallback.
|
||||||
|
func (s *Server) decorateSeriesStatuses(ctx context.Context, collections ...[]json.RawMessage) {
|
||||||
|
if s.store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tvdbIDs := []int{}
|
||||||
|
seen := map[int]bool{}
|
||||||
|
identities := make([]map[int]int, len(collections))
|
||||||
|
for collectionIndex, items := range collections {
|
||||||
|
identities[collectionIndex] = map[int]int{}
|
||||||
|
for itemIndex, raw := range items {
|
||||||
|
tvdbID := seriesTVDBID(raw)
|
||||||
|
if tvdbID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
identities[collectionIndex][itemIndex] = tvdbID
|
||||||
|
if !seen[tvdbID] {
|
||||||
|
seen[tvdbID] = true
|
||||||
|
tvdbIDs = append(tvdbIDs, tvdbID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tvdbIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses, err := s.store.LatestSonarrSeriesStatuses(ctx, tvdbIDs)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("stored Sonarr series statuses unavailable", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for collectionIndex, itemIndexes := range identities {
|
||||||
|
for itemIndex, tvdbID := range itemIndexes {
|
||||||
|
status := canonicalSonarrSeriesStatus(statuses[tvdbID])
|
||||||
|
if status != "" {
|
||||||
|
collections[collectionIndex][itemIndex] = injectSeriesStatus(
|
||||||
|
collections[collectionIndex][itemIndex], status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seriesTVDBID(raw json.RawMessage) int {
|
||||||
|
var item struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &item) != nil || !strings.EqualFold(item.Type, "Series") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
id, _ := strconv.Atoi(providerID(item.ProviderIDs, "tvdb"))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalSonarrSeriesStatus is deliberately conservative. Unknown values are not
|
||||||
|
// copied over a usable Emby status, and American spelling is normalised at the render
|
||||||
|
// boundary before it can become viewer-facing text.
|
||||||
|
func canonicalSonarrSeriesStatus(status string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||||
|
case "continuing":
|
||||||
|
return "Continuing"
|
||||||
|
case "ended":
|
||||||
|
return "Ended"
|
||||||
|
case "cancelled", "canceled":
|
||||||
|
return "Cancelled"
|
||||||
|
case "upcoming":
|
||||||
|
return "Upcoming"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectSeriesStatus(raw json.RawMessage, status string) json.RawMessage {
|
||||||
|
if status == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
var members map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &members) != nil || members == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(status)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
members["Status"] = encoded
|
||||||
|
out, err := json.Marshal(members)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanonicalSonarrSeriesStatus(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
" continuing ": "Continuing",
|
||||||
|
"ended": "Ended",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"canceled": "Cancelled",
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"deleted": "",
|
||||||
|
"unknown": "",
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
if got := canonicalSonarrSeriesStatus(input); got != want {
|
||||||
|
t.Errorf("canonicalSonarrSeriesStatus(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectSeriesStatusReplacesEmbyWithoutDroppingFields(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"Id":"series-1","Status":"Continuing","FutureField":true}`)
|
||||||
|
decorated := injectSeriesStatus(raw, "Ended")
|
||||||
|
var item struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
FutureField bool `json:"FutureField"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(decorated, &item); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item.Status != "Ended" || !item.FutureField {
|
||||||
|
t.Fatalf("decorated item = %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeriesTVDBIDOnlyAcceptsSeries(t *testing.T) {
|
||||||
|
series := json.RawMessage(`{"Type":"Series","ProviderIds":{"Tvdb":"1234"}}`)
|
||||||
|
if got := seriesTVDBID(series); got != 1234 {
|
||||||
|
t.Fatalf("series TVDB id = %d, want 1234", got)
|
||||||
|
}
|
||||||
|
movie := json.RawMessage(`{"Type":"Movie","ProviderIds":{"Tvdb":"1234"}}`)
|
||||||
|
if got := seriesTVDBID(movie); got != 0 {
|
||||||
|
t.Fatalf("movie TVDB id = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,46 @@ type SonarrSeriesStatusChange struct {
|
|||||||
Current SonarrSeriesStatus
|
Current SonarrSeriesStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatestSonarrSeriesStatuses returns Sonarr's most recently observed lifecycle for each
|
||||||
|
// TVDB id. The history table is change-only, so the newest row is also the current value.
|
||||||
|
func (s *Store) LatestSonarrSeriesStatuses(
|
||||||
|
ctx context.Context, tvdbIDs []int,
|
||||||
|
) (map[int]string, error) {
|
||||||
|
statuses := map[int]string{}
|
||||||
|
if len(tvdbIDs) == 0 {
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT ON (tvdb_id) tvdb_id, status
|
||||||
|
FROM sonarr_series_status_history
|
||||||
|
WHERE tvdb_id = ANY($1) AND tvdb_id > 0
|
||||||
|
ORDER BY tvdb_id, observed_at DESC, id DESC`, tvdbIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: read latest Sonarr series statuses: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var tvdbID int
|
||||||
|
var status string
|
||||||
|
if err := rows.Scan(&tvdbID, &status); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan latest Sonarr series status: %w", err)
|
||||||
|
}
|
||||||
|
statuses[tvdbID] = status
|
||||||
|
}
|
||||||
|
return statuses, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SonarrSeriesStatusRevision is an opaque, monotonic version of the stored lifecycle
|
||||||
|
// catalogue. It changes whenever a scan records a first sighting or a transition.
|
||||||
|
func (s *Store) SonarrSeriesStatusRevision(ctx context.Context) (int64, error) {
|
||||||
|
var revision int64
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT COALESCE(max(id), 0) FROM sonarr_series_status_history`).Scan(&revision); err != nil {
|
||||||
|
return 0, fmt.Errorf("store: read Sonarr series status revision: %w", err)
|
||||||
|
}
|
||||||
|
return revision, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
|
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
|
||||||
_, err := s.pool.Exec(ctx, `
|
_, err := s.pool.Exec(ctx, `
|
||||||
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
|
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
|
||||||
|
|||||||
Reference in New Issue
Block a user