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
|
||||
named and far ones rounded to weeks or months — a pace measured over a fortnight cannot
|
||||
honestly pick a day four months out.
|
||||
- **"Catch up" is not a synonym for "finish".** `BaseItem.isOngoingSeries` prefers Sonarr's
|
||||
lifecycle where the gateway attached one and falls back to Emby's `Status`, which is the
|
||||
only source the direct path has; both absent means "finish", the weaker claim. `Status`
|
||||
is in `fieldsDetail` on both paths for this, and the gateway's item cache key moved to
|
||||
`item:v5:` so entries written before it cannot hide the field.
|
||||
- **"Catch up" is not a synonym for "finish".** `Status` is the canonical series-detail
|
||||
field on both paths. The gateway replaces Emby's value with the latest recognised Sonarr
|
||||
observation stored by the existing daily lifecycle scan, while direct mode keeps Emby as
|
||||
its only source; both absent means "finish", the weaker claim. The scan history's maximum
|
||||
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
|
||||
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
|
||||
|
||||
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-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="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
|
||||
</head>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { SearchesPage } from './pages/Searches';
|
||||
import { ViewsPage } from './pages/Views';
|
||||
import { MediaReportsPage } from './pages/MediaReports';
|
||||
import { NotificationsPage } from './pages/Notifications';
|
||||
import { NotificationSettingsPage } from './pages/NotificationSettings';
|
||||
import { CreditsPage } from './pages/Credits';
|
||||
|
||||
/* The console's routing table.
|
||||
@@ -109,6 +110,7 @@ export function App() {
|
||||
<Route path="searches" element={<SearchesPage />} />
|
||||
<Route path="media-reports" element={<MediaReportsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="notification-settings" element={<NotificationSettingsPage />} />
|
||||
|
||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||
still links there lands on the overview rather than on a 404. */}
|
||||
|
||||
@@ -797,6 +797,22 @@ export interface GatewaySettingsResponse {
|
||||
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 ---------- */
|
||||
|
||||
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.',
|
||||
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',
|
||||
path: '/admin/media-reports',
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Tiles,
|
||||
Toggle,
|
||||
} 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
|
||||
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[];
|
||||
}
|
||||
|
||||
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
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
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."
|
||||
actions={<Link className="btn" to="/admin/notification-settings">TV notification settings</Link>}
|
||||
/>
|
||||
|
||||
<Banner message={log.error} />
|
||||
|
||||
@@ -62,7 +62,7 @@ val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
val defaultVersionName = "0.3.06"
|
||||
val defaultVersionName = "0.3.07"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -92,6 +92,7 @@ class MaintenanceMonitor(
|
||||
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _seriesStatusRevision = MutableStateFlow(0L)
|
||||
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
@@ -136,6 +137,12 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
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. */
|
||||
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
|
||||
val metadataHeroTimeRemainingColour: StateFlow<String> =
|
||||
@@ -288,6 +295,7 @@ class MaintenanceMonitor(
|
||||
// clearing it here would fight that loop for the same flow.
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_seriesStatusRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
@@ -327,6 +335,7 @@ class MaintenanceMonitor(
|
||||
null
|
||||
}
|
||||
_preferencesRevision.value = status.preferencesRevision
|
||||
_seriesStatusRevision.value = status.seriesStatusRevision
|
||||
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
|
||||
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
||||
.distinct()
|
||||
@@ -382,6 +391,7 @@ class MaintenanceMonitor(
|
||||
_compatibility.value = null
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_seriesStatusRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
|
||||
@@ -416,9 +416,9 @@ data class BaseItem(
|
||||
@SerialName("SeriesId") val seriesId: String? = null,
|
||||
@SerialName("SeriesName") val seriesName: String? = null,
|
||||
/**
|
||||
* Emby's production status for a series — "Continuing" or "Ended". Only the detail
|
||||
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
|
||||
* absence as "not known to be running" rather than guessing either way.
|
||||
* Canonical production status for a series. In gateway mode the detail response
|
||||
* replaces Emby's value with the latest stored Sonarr observation where one matches;
|
||||
* direct mode keeps Emby as the only source.
|
||||
*/
|
||||
@SerialName("Status") val status: String? = null,
|
||||
// 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()
|
||||
|
||||
/**
|
||||
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
|
||||
* one, since it knows about a season announced but not yet imported; Emby's own
|
||||
* [status] is the fallback and the only source the direct path has. Neither present
|
||||
* means no, which keeps the pace estimate saying "finish" — the weaker claim.
|
||||
* Whether more episodes are expected. The canonical detail [status] wins; schedule
|
||||
* lifecycle metadata is only the opening-frame fallback before that record arrives.
|
||||
* Neither present means no, which keeps the pace estimate saying "finish" — the weaker
|
||||
* claim.
|
||||
*/
|
||||
val isOngoingSeries: Boolean
|
||||
get() = when {
|
||||
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
|
||||
else -> status.equals("Continuing", ignoreCase = true)
|
||||
status != null -> status.equals("Continuing", ignoreCase = true)
|
||||
else -> membyLifecycle.equals("continuing", ignoreCase = true)
|
||||
}
|
||||
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
||||
|
||||
|
||||
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
|
||||
* on the poll the app is already making.
|
||||
*/
|
||||
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. */
|
||||
val metadataHeroContentOrder: List<String> = emptyList(),
|
||||
/** Household-wide colour for the optional time-remaining caption: green or white. */
|
||||
|
||||
@@ -324,6 +324,7 @@ internal data class DetailsReturn(
|
||||
internal fun FocusedDetailsOverlay(
|
||||
homeViewModel: HomeViewModel,
|
||||
selected: BaseItem,
|
||||
seriesStatusRevision: Long = 0,
|
||||
restorePosition: Boolean,
|
||||
onPlay: (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
|
||||
// still consulted for live user state, but it cannot replace the full record with the
|
||||
// lightweight Search card when focus returns behind the overlay.
|
||||
var detailMetadata by remember(selected.id) {
|
||||
mutableStateOf(homeViewModel.detailMetadataSnapshot(selected.id))
|
||||
var detailMetadata by remember(selected.id, seriesStatusRevision) {
|
||||
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) {
|
||||
homeViewModel.loadDetailMetadata(selected.id)?.let { detailMetadata = it }
|
||||
homeViewModel.loadDetailMetadata(selected.id, seriesStatusRevision)
|
||||
?.let { detailMetadata = it }
|
||||
}
|
||||
}
|
||||
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
|
||||
|
||||
@@ -261,6 +261,8 @@ internal fun HomeScreen(
|
||||
)
|
||||
}
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val seriesStatusRevision by
|
||||
ServiceLocator.maintenance.seriesStatusRevision.collectAsStateWithLifecycle()
|
||||
val metadataHeroContentOrder by
|
||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||
val metadataHeroTimeRemainingColour by
|
||||
@@ -2193,6 +2195,7 @@ internal fun HomeScreen(
|
||||
FocusedDetailsOverlay(
|
||||
homeViewModel = homeViewModel,
|
||||
selected = selected,
|
||||
seriesStatusRevision = seriesStatusRevision,
|
||||
restorePosition = restoreDetailPosition,
|
||||
airingNotice = detailsAiringNotice,
|
||||
onOpenItem = { related ->
|
||||
|
||||
@@ -177,6 +177,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
|
||||
}
|
||||
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
|
||||
private var seriesStatusRevision = 0L
|
||||
|
||||
/** Row engagement, buffered here and uploaded in batches. */
|
||||
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]
|
||||
* joins an existing request or starts the missing one.
|
||||
*/
|
||||
internal fun detailMetadataSnapshot(itemId: String): BaseItem? =
|
||||
synchronized(metadataCache) { metadataCache[itemId] }
|
||||
internal fun detailMetadataSnapshot(itemId: String, statusRevision: Long = 0): BaseItem? =
|
||||
synchronized(metadataCache) {
|
||||
acceptSeriesStatusRevision(statusRevision)
|
||||
metadataCache[itemId]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
var cached: BaseItem? = null
|
||||
val request = synchronized(metadataCache) {
|
||||
acceptSeriesStatusRevision(statusRevision)
|
||||
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
|
||||
if (cached != null) {
|
||||
null
|
||||
@@ -663,8 +668,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
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?> {
|
||||
val request = viewModelScope.async(
|
||||
val revisionAtStart = seriesStatusRevision
|
||||
lateinit var request: Deferred<BaseItem?>
|
||||
request = viewModelScope.async(
|
||||
context = Dispatchers.IO,
|
||||
start = CoroutineStart.LAZY,
|
||||
) {
|
||||
@@ -672,10 +688,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
runCatching { repository.getItemDetails(itemId) }
|
||||
.getOrNull()
|
||||
?.also { details ->
|
||||
synchronized(metadataCache) { metadataCache[itemId] = details }
|
||||
synchronized(metadataCache) {
|
||||
if (seriesStatusRevision == revisionAtStart) {
|
||||
metadataCache[itemId] = details
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized(metadataCache) { metadataInFlight.remove(itemId) }
|
||||
synchronized(metadataCache) {
|
||||
if (metadataInFlight[itemId] === request) {
|
||||
metadataInFlight.remove(itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
val heroFacts = remember(
|
||||
item.id,
|
||||
seasons.size,
|
||||
item.productionYear,
|
||||
item.officialRating,
|
||||
item.runTimeTicks,
|
||||
item.status,
|
||||
item.membyLifecycle,
|
||||
item.membyLifecycleText,
|
||||
) {
|
||||
heroFacts(item, seasons.size)
|
||||
heroFacts(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,
|
||||
* its production status. The score sits beside the title and the genres are a credit row,
|
||||
* so non-series titles stay at three items and never have to compete for the width.
|
||||
*
|
||||
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
|
||||
* The quiet line directly under the title. A series uses the stable three-part contract
|
||||
* year, episode runtime and production status; other media keeps its certificate there.
|
||||
* The score sits beside the title and the genres are a credit row.
|
||||
*/
|
||||
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
||||
fun heroFacts(item: BaseItem): List<String> = buildList {
|
||||
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.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
if (item.isSeries) {
|
||||
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? {
|
||||
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.status?.takeIf(String::isNotBlank)
|
||||
?: return null
|
||||
return when (status.trim().lowercase(Locale.US)) {
|
||||
"continuing" -> "Continuing"
|
||||
"ended" -> "Ended"
|
||||
"cancelled", "canceled" -> "Cancelled"
|
||||
"upcoming" -> "Upcoming"
|
||||
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 androidx.activity.ComponentActivity
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -243,6 +245,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
||||
private var pausePosterUrl: String? = null
|
||||
private var pauseOverlay: View? = null
|
||||
private val pauseHeroVisible = mutableStateOf(false)
|
||||
private val pauseHeroMetadata = mutableStateOf(PauseMediaHeroMetadata())
|
||||
private var nowPlayingGroup: View? = null
|
||||
private var playbackIdentityView: View? = null
|
||||
private var playbackIdentityHideJob: Job? = null
|
||||
@@ -1335,7 +1339,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
updatePauseHeroMetadata()
|
||||
}
|
||||
|
||||
private fun startPreroll() {
|
||||
@@ -2358,7 +2362,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
*/
|
||||
private fun showPlaybackIdentity() {
|
||||
val identity = playbackIdentityView ?: return
|
||||
val paused = pauseOverlay?.visibility == View.VISIBLE
|
||||
val paused = pauseHeroVisible.value
|
||||
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
|
||||
MembyDiagnostics.debug(
|
||||
"station_ident_withheld",
|
||||
@@ -2440,7 +2444,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
val slot = playerIdentitySlot(
|
||||
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
|
||||
transportVisible = transportVisible,
|
||||
paused = pauseOverlay?.visibility == View.VISIBLE,
|
||||
paused = pauseHeroVisible.value,
|
||||
)
|
||||
nowPlayingGroup?.visibility =
|
||||
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
|
||||
@@ -4328,19 +4332,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
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) }
|
||||
}
|
||||
}
|
||||
updatePauseHeroMetadata()
|
||||
hidePlaybackError()
|
||||
showPlaybackLoading()
|
||||
if (playback != null) {
|
||||
@@ -4378,30 +4370,47 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
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)
|
||||
// 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.
|
||||
applyIdentityRegion()
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
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) }
|
||||
}
|
||||
updatePauseHeroMetadata()
|
||||
}
|
||||
|
||||
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) {
|
||||
val paused = playbackStarted && !prerollActive &&
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -1,80 +1,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:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<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>
|
||||
android:importantForAccessibility="no"
|
||||
android:visibility="visible" />
|
||||
|
||||
@@ -197,10 +197,11 @@ class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes the settings revision an operator push arrives as`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"preferencesRevision":12}""",
|
||||
"""{"maintenance":false,"preferencesRevision":12,"seriesStatusRevision":34}""",
|
||||
)
|
||||
|
||||
assertEquals(12L, status.preferencesRevision)
|
||||
assertEquals(34L, status.seriesStatusRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,13 +100,14 @@ class SeriesDetailsTest {
|
||||
name = "Series",
|
||||
type = "Series",
|
||||
productionYear = 2022,
|
||||
runTimeTicks = 31_200_000_000L,
|
||||
officialRating = "TV-MA",
|
||||
status = "Continuing",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("2022", "4 Seasons", "TV-MA", "Continuing"),
|
||||
heroFacts(series, seasonCount = 4),
|
||||
listOf("2022", "52m", "Continuing"),
|
||||
heroFacts(series),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,8 +121,8 @@ class SeriesDetailsTest {
|
||||
membyLifecycleText = "CANCELED",
|
||||
)
|
||||
|
||||
assertEquals("Cancelled", heroFacts(series).last())
|
||||
assertEquals("Ended", heroFacts(series.copy(membyLifecycleText = null)).last())
|
||||
assertEquals("Ended", heroFacts(series).last())
|
||||
assertEquals("Cancelled", heroFacts(series.copy(status = null)).last())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -141,7 +142,7 @@ class SeriesDetailsTest {
|
||||
status = "Continuing",
|
||||
)
|
||||
|
||||
assertEquals(listOf("2022"), heroFacts(unknownSeries))
|
||||
assertEquals(listOf("2022", "Upcoming"), heroFacts(unknownSeries))
|
||||
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.ImageView
|
||||
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.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
@@ -20,23 +30,43 @@ import org.robolectric.annotation.GraphicsMode
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class PlayerPauseOverlayScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `paused movie shows focused poster synopsis and resume controls`() {
|
||||
val (activity, root, controls) = playerSurface()
|
||||
fun `paused episode shows darkened hero artwork and synopsis`() {
|
||||
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
|
||||
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
|
||||
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")
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/player-pause-overlay/player-paused-episode-hero.png",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -362,6 +362,9 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
||||
if _, ok := body["preferencesRevision"]; !ok {
|
||||
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)
|
||||
if !ok || len(metadataOrder) == 0 {
|
||||
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
|
||||
// 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.
|
||||
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"
|
||||
|
||||
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")
|
||||
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")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
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
|
||||
// cannot hide newly requested fields such as People or the stored ratings.
|
||||
func itemDetailKey(userID, itemID string) string {
|
||||
return cache.UserKey(userID, "item:v6:"+itemID)
|
||||
func (s *Server) itemDetailKey(ctx context.Context, userID, itemID string) string {
|
||||
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.
|
||||
@@ -77,7 +85,7 @@ func itemDetailKey(userID, itemID string) string {
|
||||
func (s *Server) detailItem(
|
||||
ctx context.Context, sess store.Session, itemID string,
|
||||
) (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) {
|
||||
raw, err := s.emby.Item(
|
||||
timing.WithLabel(ctx, "emby.item"), credentials(sess), itemID, fieldsDetail)
|
||||
|
||||
@@ -26,6 +26,8 @@ func seriesLifecycleTag(status string) lifecycleTag {
|
||||
return lifecycleTag{Status: "upcoming", Label: "UPCOMING"}
|
||||
case "ended":
|
||||
return lifecycleTag{Status: "ended", Label: "ENDED"}
|
||||
case "cancelled", "canceled":
|
||||
return lifecycleTag{Status: "cancelled", Label: "CANCELLED"}
|
||||
case "deleted":
|
||||
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
|
||||
default:
|
||||
|
||||
@@ -15,6 +15,8 @@ func TestSeriesLifecycleTag(t *testing.T) {
|
||||
"Continuing": {Status: "continuing", Label: "CONTINUING"},
|
||||
"upcoming": {Status: "upcoming", Label: "UPCOMING"},
|
||||
"ended": {Status: "ended", Label: "ENDED"},
|
||||
"cancelled": {Status: "cancelled", Label: "CANCELLED"},
|
||||
"canceled": {Status: "cancelled", Label: "CANCELLED"},
|
||||
"deleted": {Status: "deleted", Label: "REMOVED"},
|
||||
// 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)
|
||||
featurePolicy := s.currentFeaturePolicy(r.Context())
|
||||
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{
|
||||
"maintenance": state.Enabled,
|
||||
"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
|
||||
// delivery channel for an operator pushing someone's settings.
|
||||
"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
|
||||
// layout or colour reaches an open launcher immediately, without manufacturing a
|
||||
// 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.
|
||||
//
|
||||
// It attaches both of the things Memby knows about a title that Emby's payload does not
|
||||
// carry: the stored review scores, and — for a shadow viewer — whose progress this is. The
|
||||
// two are separate concerns and stayed separate functions, but every call site wanted both,
|
||||
// and a decoration added at seven sites is a decoration missing from the eighth.
|
||||
// It attaches the facts Memby knows about a title beyond Emby's payload: the latest stored
|
||||
// Sonarr series status, stored review scores, and — for a shadow viewer — whose progress
|
||||
// this is. The concerns stay in separate functions, but every call site needs one door;
|
||||
// a decoration added at seven sites is a decoration missing from the eighth.
|
||||
func (s *Server) decorateItems(ctx context.Context, collections ...[]json.RawMessage) {
|
||||
s.decorateSeriesStatuses(ctx, collections...)
|
||||
s.decorateItemRatings(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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
|
||||
|
||||
Reference in New Issue
Block a user