0.3.00
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
||||
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-CJIn-EsM.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-Db7bWHnD.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-Bx4XLLdG.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BS_y3llT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { RequestsPage } from './pages/Requests';
|
||||
import { RecommendationsPage } from './pages/Recommendations';
|
||||
import { InspectorPage } from './pages/Inspector';
|
||||
import { HeroPage } from './pages/Hero';
|
||||
import { MetadataHeroPage } from './pages/MetadataHero';
|
||||
import { FeaturesPage } from './pages/Features';
|
||||
import { PlaybackPage } from './pages/Playback';
|
||||
import { SubtitlesPage } from './pages/Subtitles';
|
||||
@@ -81,6 +82,7 @@ export function App() {
|
||||
<Route path="inspector" element={<InspectorPage />} />
|
||||
|
||||
<Route path="hero" element={<HeroPage />} />
|
||||
<Route path="metadata-hero" element={<MetadataHeroPage />} />
|
||||
<Route path="features" element={<FeaturesPage />} />
|
||||
<Route path="playback" element={<PlaybackPage />} />
|
||||
<Route path="subtitles" element={<SubtitlesPage />} />
|
||||
|
||||
@@ -779,6 +779,25 @@ export interface GatewaySettingsResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
/* ---------- metadata hero ---------- */
|
||||
|
||||
export interface MetadataHeroOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MetadataHeroSettings {
|
||||
contentOrder: string[];
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface MetadataHeroSettingsResponse {
|
||||
settings: MetadataHeroSettings;
|
||||
options: MetadataHeroOption[];
|
||||
}
|
||||
|
||||
/** IngestJob is one thing Sonarr or Radarr said changed. The key is derived from the file
|
||||
* rather than the delivery, which is what makes a repeated webhook one row. */
|
||||
export interface IngestJob {
|
||||
|
||||
@@ -185,6 +185,14 @@ export const nav: NavGroup[] = [
|
||||
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'metadata-hero',
|
||||
path: '/admin/metadata-hero',
|
||||
label: 'Metadata hero',
|
||||
title: 'Metadata hero',
|
||||
intro: 'Order the focused-title information shown above browse rows on every television.',
|
||||
icon: 'tv',
|
||||
},
|
||||
{
|
||||
id: 'recommendations',
|
||||
path: '/admin/recommendations',
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { MetadataHeroSettingsResponse } from '../api/types';
|
||||
import { Banner, Button, Card, Loading, PageHead, Toggle } from '../components/ui';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
|
||||
const DEFAULT_ORDER = ['title', 'ratings', 'facts', 'summary'];
|
||||
|
||||
export function MetadataHeroPage() {
|
||||
const query = useQuery<MetadataHeroSettingsResponse>('/admin/api/metadata-hero');
|
||||
const { busy, run } = useAction();
|
||||
const { wrap } = useToast();
|
||||
const [order, setOrder] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (order === null && query.data) setOrder(query.data.settings.contentOrder);
|
||||
}, [order, query.data]);
|
||||
|
||||
const selected = order ?? DEFAULT_ORDER;
|
||||
const options = query.data?.options ?? [];
|
||||
const displayOptions = [
|
||||
...selected,
|
||||
...options.map((option) => option.value).filter((value) => !selected.includes(value)),
|
||||
];
|
||||
const move = (value: string, offset: number) => {
|
||||
const from = selected.indexOf(value);
|
||||
const to = from + offset;
|
||||
if (from < 0 || to < 0 || to >= selected.length) return;
|
||||
const next = [...selected];
|
||||
const moved = next[from];
|
||||
next[from] = next[to]!;
|
||||
next[to] = moved!;
|
||||
setOrder(next);
|
||||
};
|
||||
const save = () => run('save', async () => {
|
||||
const response = await wrap(
|
||||
() => api.post<MetadataHeroSettingsResponse>('/admin/api/metadata-hero', { contentOrder: selected }),
|
||||
'Metadata hero saved for every television.',
|
||||
);
|
||||
if (response) {
|
||||
query.set(response);
|
||||
setOrder(response.settings.contentOrder);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Metadata hero"
|
||||
intro="Choose which content appears beside focused browse cards, and the order every television uses."
|
||||
/>
|
||||
<Banner message={query.error} />
|
||||
{query.loading ? <Loading rows={2} /> : (
|
||||
<div className="metadata-hero-layout">
|
||||
<Card
|
||||
title="Content order"
|
||||
intro="Enabled blocks are drawn from top to bottom. Changes apply to the whole household."
|
||||
icon="sliders"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
||||
Save metadata hero
|
||||
</Button>
|
||||
<Button onClick={() => setOrder(DEFAULT_ORDER)}>Restore default order</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="metadata-hero-order">
|
||||
{displayOptions.map((value) => {
|
||||
const option = options.find((entry) => entry.value === value);
|
||||
if (!option) return null;
|
||||
const index = selected.indexOf(value);
|
||||
const enabled = index >= 0;
|
||||
return (
|
||||
<div className="metadata-hero-order-row" data-enabled={enabled || undefined} key={value}>
|
||||
<span className="metadata-hero-order-number">{enabled ? index + 1 : '—'}</span>
|
||||
<Toggle
|
||||
label={option.label}
|
||||
hint={option.description}
|
||||
checked={enabled}
|
||||
disabled={enabled && selected.length === 1}
|
||||
onChange={(on) => setOrder(on
|
||||
? [...selected, value]
|
||||
: selected.filter((entry) => entry !== value))}
|
||||
/>
|
||||
<div className="metadata-hero-order-actions">
|
||||
<Button size="sm" variant="quiet" disabled={!enabled || index === 0}
|
||||
title={`Move ${option.label} up`} onClick={() => move(value, -1)}>↑</Button>
|
||||
<Button size="sm" variant="quiet" disabled={!enabled || index === selected.length - 1}
|
||||
title={`Move ${option.label} down`} onClick={() => move(value, 1)}>↓</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Television preview"
|
||||
intro="A representative focused film at launcher scale and in the current order."
|
||||
icon="tv"
|
||||
>
|
||||
<div className="metadata-hero-preview" aria-label="Metadata hero preview">
|
||||
<div className="metadata-hero-preview-copy">
|
||||
{selected.map((section) => {
|
||||
if (section === 'title') return <div className="metadata-preview-title" key={section}>THE SOUTHERN LIGHT</div>;
|
||||
if (section === 'ratings') return <div className="metadata-preview-ratings" key={section}><b>IMDb 8.2</b><b>TMDb 81%</b></div>;
|
||||
if (section === 'facts') return <div className="metadata-preview-facts" key={section}>
|
||||
<span>2026 • 1h 54m • M • Drama, Mystery</span>
|
||||
<b>4K</b><b>5.1</b>
|
||||
</div>;
|
||||
if (section === 'recommendation_reason') return <div className="metadata-preview-reason" key={section}>Because you watched Harbour Lights</div>;
|
||||
if (section === 'time_remaining') return <div className="metadata-preview-progress" key={section}>
|
||||
<span><i /></span><b>47 MINUTES REMAINING</b>
|
||||
</div>;
|
||||
if (section === 'summary') return <p className="metadata-preview-summary" key={section}>A quiet coastal town follows an unexpected signal across the winter sky.</p>;
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<div className="metadata-hero-preview-art" aria-hidden="true" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3660,3 +3660,154 @@ details summary {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---------- metadata hero ---------- */
|
||||
|
||||
.metadata-hero-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, .82fr) minmax(460px, 1.35fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.metadata-hero-order {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.metadata-hero-order-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-lift);
|
||||
opacity: .62;
|
||||
}
|
||||
.metadata-hero-order-row[data-enabled] { opacity: 1; }
|
||||
.metadata-hero-order-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metadata-hero-order-actions { display: flex; gap: 5px; }
|
||||
.metadata-hero-order-actions button { min-width: 34px; padding-inline: 8px; }
|
||||
.metadata-hero-preview {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 7.2;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #080b0e;
|
||||
}
|
||||
.metadata-hero-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(90deg, #080b0e 0 34%, rgba(8,11,14,.9) 48%, rgba(8,11,14,.2) 76%, transparent);
|
||||
}
|
||||
.metadata-hero-preview-art {
|
||||
position: absolute;
|
||||
inset: 0 0 0 36%;
|
||||
z-index: -2;
|
||||
background:
|
||||
radial-gradient(circle at 67% 24%, rgba(211,242,255,.8) 0 2%, transparent 3%),
|
||||
radial-gradient(ellipse at 76% 78%, rgba(71,107,92,.8), transparent 45%),
|
||||
linear-gradient(145deg, #17303d 0%, #456273 42%, #101a1f 100%);
|
||||
}
|
||||
.metadata-hero-preview-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 54%;
|
||||
min-height: 100%;
|
||||
padding: 26px 0 24px 28px;
|
||||
}
|
||||
.metadata-preview-title {
|
||||
max-width: 330px;
|
||||
color: #fff;
|
||||
font-family: Georgia, serif;
|
||||
font-size: clamp(24px, 3vw, 42px);
|
||||
line-height: .95;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.metadata-preview-ratings { display: flex; gap: 8px; }
|
||||
.metadata-preview-ratings b {
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,.11);
|
||||
color: #e8edf0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.metadata-preview-facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: #bdc7ce;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metadata-preview-facts span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.metadata-preview-facts b {
|
||||
flex: none;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid rgba(255,255,255,.28);
|
||||
border-radius: 3px;
|
||||
background: rgba(0,0,0,.22);
|
||||
color: #e8edf0;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.metadata-preview-reason {
|
||||
overflow: hidden;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metadata-preview-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #bdc7ce;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metadata-preview-progress > span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 112px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,255,255,.22);
|
||||
}
|
||||
.metadata-preview-progress i {
|
||||
display: block;
|
||||
width: 59%;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.metadata-preview-progress b { font-size: inherit; line-height: inherit; }
|
||||
.metadata-preview-summary { margin: 0; color: #cbd2d7; font-size: 12px; line-height: 1.45; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.metadata-hero-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
+1
-18
@@ -38,15 +38,6 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
|
||||
// the complete legal documents offline. Deployments can override the public source URL
|
||||
// without changing application code.
|
||||
val membySourceUrl: String =
|
||||
(project.findProperty("memby.sourceUrl") as String?)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: "https://g.sublogue.com/admin/memby"
|
||||
|
||||
fun buildConfigString(value: String): String =
|
||||
"\"" + value
|
||||
.replace("\\", "\\\\")
|
||||
@@ -58,13 +49,8 @@ fun buildConfigString(value: String): String =
|
||||
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
|
||||
val changelogText = rootProject.file("CHANGELOG.md").readText()
|
||||
val gplLicenseText = rootProject.file("LICENSE").readText()
|
||||
val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.99"
|
||||
val defaultVersionName = "0.3.00"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -128,9 +114,6 @@ extensions.configure<ApplicationExtension> {
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
||||
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
|
||||
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
|
||||
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ class MaintenanceMonitor(
|
||||
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
private val _heroRevision = MutableStateFlow("")
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
@@ -131,6 +132,9 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
|
||||
|
||||
/** One household-wide composition, delivered by the status poll to every viewer. */
|
||||
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the operator wants TVs that cannot install their own updates to be asked for
|
||||
* the permission. Pushed through the same poll as everything else, and false whenever
|
||||
@@ -278,8 +282,9 @@ class MaintenanceMonitor(
|
||||
// clearing it here would fight that loop for the same flow.
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -314,6 +319,10 @@ class MaintenanceMonitor(
|
||||
null
|
||||
}
|
||||
_preferencesRevision.value = status.preferencesRevision
|
||||
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
|
||||
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
||||
.distinct()
|
||||
.ifEmpty { DEFAULT_METADATA_HERO_CONTENT_ORDER }
|
||||
_theme.value = status.theme
|
||||
_heroRevision.value = status.hero.revision
|
||||
_installPermissionPrompt.value =
|
||||
@@ -357,6 +366,7 @@ class MaintenanceMonitor(
|
||||
_compatibility.value = null
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
@@ -461,6 +471,10 @@ class MaintenanceMonitor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val DEFAULT_METADATA_HERO_CONTENT_ORDER =
|
||||
listOf("title", "ratings", "facts", "summary")
|
||||
internal val METADATA_HERO_CONTENT_OPTIONS =
|
||||
DEFAULT_METADATA_HERO_CONTENT_ORDER + listOf("recommendation_reason", "time_remaining")
|
||||
internal const val POLL_INTERVAL_MS = 10_000L
|
||||
internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
|
||||
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
|
||||
|
||||
@@ -242,8 +242,6 @@ internal object SettingsMigrationLogic {
|
||||
booleanPreferencesKey("show_home_card_metadata")
|
||||
] ?: true,
|
||||
showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: true,
|
||||
metadataHeroContentOrder = preferences[stringPreferencesKey("metadata_hero_content_order")]
|
||||
?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
||||
hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false,
|
||||
homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
|
||||
homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
|
||||
@@ -365,8 +363,6 @@ data class Settings(
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
/** Show third-party ratings on browse and detail surfaces. Profile-specific. */
|
||||
val showRatingsStrip: Boolean = true,
|
||||
/** Newline-separated, operator-controlled content order for the focused metadata hero. */
|
||||
val metadataHeroContentOrder: String = UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
||||
/** Hide fully watched movies from browse rows and hero selections. */
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
/** Newline-separated server row ids. These are profile-specific home customisations. */
|
||||
@@ -515,7 +511,6 @@ data class EmbyProfile(
|
||||
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
val showRatingsStrip: Boolean = true,
|
||||
val metadataHeroContentOrder: String = UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
val homeRowOrder: String = "",
|
||||
val homePinnedRows: String = "",
|
||||
@@ -595,7 +590,6 @@ class SettingsStore(private val context: Context) {
|
||||
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
|
||||
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
||||
val SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip")
|
||||
val METADATA_HERO_CONTENT_ORDER = stringPreferencesKey("metadata_hero_content_order")
|
||||
val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
|
||||
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
|
||||
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
|
||||
@@ -846,8 +840,8 @@ class SettingsStore(private val context: Context) {
|
||||
* Adopts the server's document for the signed-in viewer, in one write.
|
||||
*
|
||||
* One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
|
||||
* edit, and this touches nineteen keys plus the profiles blob — doing it through the
|
||||
* individual setters would be eighteen rewrites for one sync, on a TV that has just
|
||||
* edit, and this touches the viewer's full document plus the profiles blob — doing it
|
||||
* through the individual setters would be many rewrites for one sync, on a TV that has just
|
||||
* started up.
|
||||
*
|
||||
* The revision is stored in the same edit as the values it describes. If they could
|
||||
@@ -868,7 +862,6 @@ class SettingsStore(private val context: Context) {
|
||||
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
|
||||
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
|
||||
store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip
|
||||
store[Keys.METADATA_HERO_CONTENT_ORDER] = preferences.metadataHeroContentOrder.joinToString("\n")
|
||||
store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
|
||||
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
|
||||
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
|
||||
@@ -895,7 +888,6 @@ class SettingsStore(private val context: Context) {
|
||||
homeArtworkStyle = preferences.homeArtworkStyle,
|
||||
showHomeCardMetadata = preferences.showHomeCardMetadata,
|
||||
showRatingsStrip = preferences.showRatingsStrip,
|
||||
metadataHeroContentOrder = preferences.metadataHeroContentOrder.joinToString("\n"),
|
||||
hideWatchedMovies = preferences.hideWatchedMovies,
|
||||
showTitleLogo = preferences.showTitleLogo,
|
||||
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
|
||||
@@ -1356,8 +1348,6 @@ class SettingsStore(private val context: Context) {
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
|
||||
showRatingsStrip = previous?.showRatingsStrip ?: true,
|
||||
metadataHeroContentOrder = previous?.metadataHeroContentOrder
|
||||
?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
||||
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
|
||||
homeRowOrder = previous?.homeRowOrder.orEmpty(),
|
||||
homePinnedRows = previous?.homePinnedRows.orEmpty(),
|
||||
@@ -1536,7 +1526,6 @@ class SettingsStore(private val context: Context) {
|
||||
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
||||
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
|
||||
preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
|
||||
preferences[Keys.METADATA_HERO_CONTENT_ORDER] = profile.metadataHeroContentOrder
|
||||
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
|
||||
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
|
||||
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
|
||||
@@ -1580,8 +1569,6 @@ class SettingsStore(private val context: Context) {
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
|
||||
metadataHeroContentOrder = preferences[Keys.METADATA_HERO_CONTENT_ORDER]
|
||||
?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
||||
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||
@@ -1650,8 +1637,6 @@ class SettingsStore(private val context: Context) {
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
|
||||
metadataHeroContentOrder = preferences[Keys.METADATA_HERO_CONTENT_ORDER]
|
||||
?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
||||
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||
|
||||
@@ -36,8 +36,6 @@ data class UserPreferences(
|
||||
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
val showRatingsStrip: Boolean = true,
|
||||
/** Operator-controlled order of the focused metadata hero's content blocks. */
|
||||
val metadataHeroContentOrder: List<String> = DEFAULT_METADATA_HERO_CONTENT_ORDER,
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
val showTitleLogo: Boolean = true,
|
||||
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
@@ -75,7 +73,6 @@ data class UserPreferences(
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
|
||||
val DEFAULT_METADATA_HERO_CONTENT_ORDER = listOf("title", "ratings", "facts", "summary")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +90,6 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
|
||||
homeArtworkStyle = homeArtworkStyle,
|
||||
showHomeCardMetadata = showHomeCardMetadata,
|
||||
showRatingsStrip = showRatingsStrip,
|
||||
metadataHeroContentOrder = metadataHeroContentOrder.decodeLineList(),
|
||||
hideWatchedMovies = hideWatchedMovies,
|
||||
showTitleLogo = showTitleLogo,
|
||||
welcomeQuoteStyle = welcomeQuoteStyle,
|
||||
@@ -138,9 +134,6 @@ fun decodeUserPreferences(
|
||||
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
|
||||
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
|
||||
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
|
||||
metadataHeroContentOrder = json.stringList(
|
||||
"metadataHeroContentOrder", fallback.metadataHeroContentOrder,
|
||||
),
|
||||
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
|
||||
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
|
||||
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
|
||||
@@ -176,7 +169,6 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
|
||||
put("homeArtworkStyle", homeArtworkStyle)
|
||||
put("showHomeCardMetadata", showHomeCardMetadata)
|
||||
put("showRatingsStrip", showRatingsStrip)
|
||||
putJsonArray("metadataHeroContentOrder") { metadataHeroContentOrder.forEach { add(JsonPrimitive(it)) } }
|
||||
put("hideWatchedMovies", hideWatchedMovies)
|
||||
put("showTitleLogo", showTitleLogo)
|
||||
put("welcomeQuoteStyle", welcomeQuoteStyle)
|
||||
|
||||
@@ -235,6 +235,8 @@ data class GatewayServiceStatus(
|
||||
* on the poll the app is already making.
|
||||
*/
|
||||
val preferencesRevision: Long = 0,
|
||||
/** Household-wide order of the focused metadata hero's content blocks. */
|
||||
val metadataHeroContentOrder: List<String> = emptyList(),
|
||||
/**
|
||||
* The colour scheme this viewer's televisions should be painted, as an id and a
|
||||
* revision rather than the palette itself — the [preferencesRevision] precedent, for
|
||||
|
||||
@@ -68,7 +68,6 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.decodeLineList
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||
@@ -223,6 +222,8 @@ internal fun HomeScreen(
|
||||
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
|
||||
val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val metadataHeroContentOrder by
|
||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
||||
@@ -1430,7 +1431,7 @@ internal fun HomeScreen(
|
||||
} else {
|
||||
FocusedHomeMetadata(
|
||||
homeViewModel = homeViewModel,
|
||||
metadataHeroContentOrder = settings.metadataHeroContentOrder.decodeLineList(),
|
||||
metadataHeroContentOrder = metadataHeroContentOrder,
|
||||
modifier = Modifier
|
||||
.height(metadataHeight)
|
||||
// LazyColumn is drawn later as a sibling. Keep the hero
|
||||
|
||||
@@ -82,6 +82,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
@@ -136,12 +137,19 @@ private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
@Composable
|
||||
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
||||
fun MediaBadge(
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = 11.sp,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 11.sp,
|
||||
fontSize = fontSize,
|
||||
lineHeight = lineHeight,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
modifier = modifier
|
||||
.border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
|
||||
.background(Color.Black.copy(alpha = 0.22f), RoundedCornerShape(4.dp))
|
||||
|
||||
@@ -24,12 +24,10 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.weight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -70,6 +72,8 @@ private val QuietText: Color get() = MembyQuietText
|
||||
private val BACKDROP_SETTLE_DELAY = 240.milliseconds
|
||||
private val MetadataHeroLogoMaxWidth = 360.dp
|
||||
private val MetadataHeroLogoMaxHeight = 72.dp
|
||||
private val MetadataHeroFactsFontSize = 13.sp
|
||||
private val MetadataHeroFactsLineHeight = 18.sp
|
||||
|
||||
@Composable
|
||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||
@@ -229,19 +233,45 @@ private fun MetadataContent(
|
||||
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
MetadataHeroSection.Facts -> {
|
||||
// One information block between ratings and plot: catalogue facts on
|
||||
// the first line, then the file's picture and sound options beneath.
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(facts.joinToString(FactSeparator), color = MutedText, fontSize = 13.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
|
||||
RecommendationReasonCaption(recommendationReasonSummary(it), Modifier.fillMaxWidth())
|
||||
}
|
||||
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
badges.forEach { MediaBadge(it) }
|
||||
// Catalogue facts and the outlined file badges are one metadata line.
|
||||
// Badges are measured first so Stereo/5.1 and picture formats remain
|
||||
// visible; a long genre list gives way through ellipsis before the
|
||||
// file information wraps or escapes the hero.
|
||||
if (facts.isNotEmpty() || badges.isNotEmpty()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
modifier = Modifier.fillMaxWidth().clipToBounds(),
|
||||
) {
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = MetadataHeroFactsFontSize,
|
||||
lineHeight = MetadataHeroFactsLineHeight,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
badges.forEach { badge ->
|
||||
MediaBadge(
|
||||
label = badge,
|
||||
fontSize = MetadataHeroFactsFontSize,
|
||||
lineHeight = MetadataHeroFactsLineHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MetadataHeroSection.RecommendationReason ->
|
||||
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
|
||||
RecommendationReasonCaption(
|
||||
recommendationReasonSummary(it),
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item)
|
||||
MetadataHeroSection.Summary -> Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
||||
@@ -259,7 +289,10 @@ private object MetadataHeroSection {
|
||||
const val Ratings = "ratings"
|
||||
const val Facts = "facts"
|
||||
const val Summary = "summary"
|
||||
val all = listOf(Title, Ratings, Facts, Summary)
|
||||
const val RecommendationReason = "recommendation_reason"
|
||||
const val TimeRemaining = "time_remaining"
|
||||
val defaults = listOf(Title, Ratings, Facts, Summary)
|
||||
val all = defaults + listOf(RecommendationReason, TimeRemaining)
|
||||
}
|
||||
|
||||
internal fun metadataHeroContentOrder(order: List<String>): List<String> =
|
||||
@@ -269,7 +302,7 @@ internal fun metadataHeroContentOrder(order: List<String>): List<String> =
|
||||
.map { if (it == "genres") MetadataHeroSection.Facts else it }
|
||||
.filter { it in MetadataHeroSection.all }
|
||||
.distinct()
|
||||
.ifEmpty { MetadataHeroSection.all }
|
||||
.ifEmpty { MetadataHeroSection.defaults }
|
||||
|
||||
/** Catalogue information shown as one block between the ratings strip and the plot. */
|
||||
internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
|
||||
@@ -385,21 +418,10 @@ private fun ScheduleMetadataContent(
|
||||
|
||||
@Composable
|
||||
private fun MetadataStatus(item: BaseItem) {
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
val progress = if (runtime > 0) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (progress > 0f) {
|
||||
Box(
|
||||
Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(progress).height(4.dp).background(EmbyGreen, RoundedCornerShape(2.dp)))
|
||||
}
|
||||
Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp)
|
||||
}
|
||||
if (item.userData?.played == true) {
|
||||
Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
|
||||
Text("Watched", color = MutedText, fontSize = 13.sp)
|
||||
@@ -410,3 +432,41 @@ private fun MetadataStatus(item: BaseItem) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataTimeRemaining(item: BaseItem) {
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
val minutes = metadataHeroMinutesRemaining(item) ?: return
|
||||
val progress = (position.toFloat() / runtime).coerceIn(0f, 1f)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.width(150.dp).height(4.dp)
|
||||
.background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)),
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth(progress).height(4.dp)
|
||||
.background(EmbyGreen, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (minutes == 1) "1 MINUTE REMAINING" else "$minutes MINUTES REMAINING",
|
||||
color = MutedText,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? {
|
||||
val position = item.userData?.playbackPositionTicks ?: return null
|
||||
val runtime = item.runTimeTicks ?: return null
|
||||
if (position <= 0L || runtime <= position) return null
|
||||
return ((runtime - position) + TICKS_PER_MINUTE - 1L) / TICKS_PER_MINUTE
|
||||
}
|
||||
|
||||
private const val TICKS_PER_MINUTE = 600_000_000L
|
||||
|
||||
@@ -4,10 +4,8 @@ import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -15,13 +13,9 @@ import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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
|
||||
@@ -33,89 +27,62 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onLongClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -25,7 +25,6 @@ class UserPreferencesTest {
|
||||
homeArtworkStyle = "poster",
|
||||
showHomeCardMetadata = false,
|
||||
showRatingsStrip = false,
|
||||
metadataHeroContentOrder = listOf("title", "ratings", "facts", "summary", "genres"),
|
||||
hideWatchedMovies = true,
|
||||
showTitleLogo = false,
|
||||
welcomeQuoteStyle = "homicidal",
|
||||
@@ -143,7 +142,6 @@ class UserPreferencesTest {
|
||||
homeArtworkStyle = "backdrop",
|
||||
showHomeCardMetadata = false,
|
||||
showRatingsStrip = false,
|
||||
metadataHeroContentOrder = "title\nratings\nfacts\ngenres\nsummary",
|
||||
hideWatchedMovies = true,
|
||||
showTitleLogo = false,
|
||||
welcomeQuoteStyle = "positive",
|
||||
@@ -164,7 +162,6 @@ class UserPreferencesTest {
|
||||
homeArtworkStyle = "backdrop",
|
||||
showHomeCardMetadata = false,
|
||||
showRatingsStrip = false,
|
||||
metadataHeroContentOrder = listOf("title", "ratings", "facts", "genres", "summary"),
|
||||
hideWatchedMovies = true,
|
||||
showTitleLogo = false,
|
||||
welcomeQuoteStyle = "positive",
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -32,7 +33,17 @@ class MetadataHeroOrderTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata information includes genres while sound remains in its badge row`() {
|
||||
fun `optional recommendation and time blocks retain their configured order`() {
|
||||
assertEquals(
|
||||
listOf("title", "recommendation_reason", "time_remaining", "summary"),
|
||||
metadataHeroContentOrder(
|
||||
listOf("title", "recommendation_reason", "time_remaining", "summary"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata information and sound provide the content for one joined facts row`() {
|
||||
val item = BaseItem(
|
||||
id = "film",
|
||||
productionYear = 2026,
|
||||
@@ -45,4 +56,15 @@ class MetadataHeroOrderTest {
|
||||
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
|
||||
assertEquals(listOf("STEREO"), mediaBadges(item))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time remaining rounds up to a whole minute`() {
|
||||
val item = BaseItem(
|
||||
id = "film",
|
||||
runTimeTicks = 100L * 60L * 10_000_000L,
|
||||
userData = UserItemData(playbackPositionTicks = 40L * 60L * 10_000_000L + 1L),
|
||||
)
|
||||
|
||||
assertEquals(60L, metadataHeroMinutesRemaining(item))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,9 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
if err := server.LoadGatewaySettings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := server.LoadMetadataHeroSettings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Every long-running worker below is started through runtimestats.Go rather than with
|
||||
// a bare `go`, so the admin console can name what is running instead of reporting a
|
||||
// count of goroutines nobody can interpret. The name is the worker's identity across
|
||||
|
||||
@@ -65,6 +65,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
||||
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||
mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||
mux.Handle("GET /admin/api/metadata-hero", s.adminAuth(s.handleAdminMetadataHeroSettings))
|
||||
mux.Handle("POST /admin/api/metadata-hero", s.adminAuth(s.handleAdminMetadataHeroSettings))
|
||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
|
||||
@@ -362,6 +362,10 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
||||
if _, ok := body["preferencesRevision"]; !ok {
|
||||
t.Fatalf("status response carried no preferences 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)
|
||||
}
|
||||
// Likewise present with no store behind it. This is the whole delivery channel for an
|
||||
// operator's hero change: a television comparing against a missing field would go on
|
||||
// drawing yesterday's hero until it was next restarted.
|
||||
|
||||
@@ -133,6 +133,8 @@ type Server struct {
|
||||
recommendationBuilds recommendationBuilds
|
||||
maintenance maintenanceState
|
||||
quietTime quietTimeState
|
||||
// metadataHeroSettings is the shared launcher composition sent to every television.
|
||||
metadataHeroSettings metadataHeroSettingsState
|
||||
// gatewaySettings is the operator's runtime amendment to what the container was
|
||||
// started with. deployedLogLevel is remembered beside the live level variable
|
||||
// because clearing an override has to restore something, and the level variable
|
||||
|
||||
@@ -153,6 +153,10 @@ 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),
|
||||
// Four small ids are the complete household-wide metadata hero composition. They
|
||||
// ride the existing poll so a saved layout reaches an open launcher immediately,
|
||||
// without manufacturing a per-user preference revision for a global change.
|
||||
"metadataHeroContentOrder": s.metadataHeroSettings.get().ContentOrder,
|
||||
// The theme, as an id and a revision rather than the palette itself — the
|
||||
// preferencesRevision precedent, for the same reason. The set refetches /v1/theme
|
||||
// only when one of these moves, which is what makes a season arriving at midnight
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type metadataHeroSettingsState struct {
|
||||
mu sync.RWMutex
|
||||
value store.MetadataHeroSettings
|
||||
}
|
||||
|
||||
func (state *metadataHeroSettingsState) get() store.MetadataHeroSettings {
|
||||
state.mu.RLock()
|
||||
defer state.mu.RUnlock()
|
||||
return store.NormalizeMetadataHeroSettings(state.value)
|
||||
}
|
||||
|
||||
func (state *metadataHeroSettingsState) set(value store.MetadataHeroSettings) {
|
||||
state.mu.Lock()
|
||||
state.value = store.NormalizeMetadataHeroSettings(value)
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
// LoadMetadataHeroSettings primes the household-wide presentation setting before the
|
||||
// status endpoint starts telling televisions what to draw.
|
||||
func (s *Server) LoadMetadataHeroSettings(ctx context.Context) error {
|
||||
settings, err := s.store.MetadataHeroSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.metadataHeroSettings.set(settings)
|
||||
return nil
|
||||
}
|
||||
|
||||
type metadataHeroOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var metadataHeroOptions = []metadataHeroOption{
|
||||
{store.MetadataHeroTitle, "Movie logo or title", "The focused title's logo artwork, with a text title as the fallback."},
|
||||
{store.MetadataHeroRatings, "Ratings", "The available third-party and community ratings."},
|
||||
{store.MetadataHeroFacts, "Metadata information", "Year, runtime, classification, genres and outlined media badges."},
|
||||
{store.MetadataHeroSummary, "Plot summary", "A short overview of the focused title."},
|
||||
{store.MetadataHeroReason, "Why this was recommended", "The gateway's short explanation, such as Because you watched a related title."},
|
||||
{store.MetadataHeroTime, "Time remaining", "A resume progress bar followed by the number of minutes remaining."},
|
||||
}
|
||||
|
||||
type metadataHeroSettingsResponse struct {
|
||||
Settings store.MetadataHeroSettings `json:"settings"`
|
||||
Options []metadataHeroOption `json:"options"`
|
||||
}
|
||||
|
||||
func (s *Server) metadataHeroSettingsResponse() metadataHeroSettingsResponse {
|
||||
return metadataHeroSettingsResponse{
|
||||
Settings: s.metadataHeroSettings.get(),
|
||||
Options: metadataHeroOptions,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminMetadataHeroSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.metadataHeroSettingsResponse())
|
||||
return
|
||||
}
|
||||
var req store.MetadataHeroSettings
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
previous := s.metadataHeroSettings.get()
|
||||
_, operator, _ := s.browserSession(r, adminSessionPurpose)
|
||||
req.UpdatedBy = operator
|
||||
stored, err := s.store.SetMetadataHeroSettings(r.Context(), req)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("metadata hero settings write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save metadata hero settings")
|
||||
return
|
||||
}
|
||||
s.metadataHeroSettings.set(stored)
|
||||
if !slices.Equal(previous.ContentOrder, stored.ContentOrder) {
|
||||
s.publishAdmin(r.Context(), adminevents.Event{
|
||||
Type: adminevents.TypeSettingsChanged,
|
||||
Severity: adminevents.SeverityInfo,
|
||||
Title: "Metadata hero changed",
|
||||
Summary: "The household-wide metadata hero content order was changed",
|
||||
Actor: operator,
|
||||
Link: "/admin/metadata-hero",
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.metadataHeroSettingsResponse())
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestMetadataHeroSettingsStateHasAUsableGlobalDefault(t *testing.T) {
|
||||
var state metadataHeroSettingsState
|
||||
got := state.get().ContentOrder
|
||||
if !reflect.DeepEqual(got, store.DefaultMetadataHeroContentOrder) {
|
||||
t.Fatalf("content order = %v, want %v", got, store.DefaultMetadataHeroContentOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataHeroSettingsStateNormalisesEveryWrite(t *testing.T) {
|
||||
var state metadataHeroSettingsState
|
||||
state.set(store.MetadataHeroSettings{ContentOrder: []string{"summary", "unknown", "summary", "title"}})
|
||||
want := []string{"summary", "title"}
|
||||
if got := state.get().ContentOrder; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("content order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -130,20 +130,6 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Description: "Show third-party ratings on browse and detail pages.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
{
|
||||
// This is operator-owned because the hero is a shared piece of the launcher's
|
||||
// composition, not a per-television preference. The multi value keeps its order.
|
||||
Key: "metadataHeroContentOrder", Name: "Metadata hero content order", Area: "Home layout",
|
||||
Description: "The order of title, ratings, metadata information and plot summary in the focused metadata hero.",
|
||||
Kind: preferenceMulti, AdminOnly: true,
|
||||
Default: []string{"title", "ratings", "facts", "summary"},
|
||||
Options: []preferenceOption{
|
||||
option("title", "Movie logo or title"),
|
||||
option("ratings", "Ratings"),
|
||||
option("facts", "Metadata information"),
|
||||
option("summary", "Plot summary"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
|
||||
Description: "Keep fully watched films out of browse rows and the hero.",
|
||||
@@ -336,12 +322,6 @@ func normalizePreference(definition preferenceDefinition, value any) any {
|
||||
if selected := stringList(value); len(selected) > 0 {
|
||||
kept := []string{}
|
||||
for _, entry := range selected {
|
||||
// Genre used to be a separate metadata-hero block. It now lives inside
|
||||
// Metadata information with runtime and the sound/video badges. Map the
|
||||
// stored token instead of silently removing that block from existing users.
|
||||
if definition.Key == "metadataHeroContentOrder" && entry == "genres" {
|
||||
entry = "facts"
|
||||
}
|
||||
if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
|
||||
kept = append(kept, entry)
|
||||
}
|
||||
|
||||
@@ -126,13 +126,12 @@ func TestNormalizePreferencesKeepsMultiOrderAndDedupes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreferencesFoldsLegacyGenreIntoMetadataInformation(t *testing.T) {
|
||||
func TestNormalizePreferencesDropsRetiredMetadataHeroSetting(t *testing.T) {
|
||||
result := normalizePreferences(map[string]any{
|
||||
"metadataHeroContentOrder": []any{"title", "ratings", "genres", "summary"},
|
||||
})
|
||||
want := []string{"title", "ratings", "facts", "summary"}
|
||||
if !reflect.DeepEqual(result["metadataHeroContentOrder"], want) {
|
||||
t.Errorf("metadataHeroContentOrder = %v, want %v", result["metadataHeroContentOrder"], want)
|
||||
if _, exists := result["metadataHeroContentOrder"]; exists {
|
||||
t.Fatal("the household-wide metadata hero setting remained in a user's preferences")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// MetadataHeroSettingsKey is the app_settings row backing the household-wide metadata
|
||||
// hero composition.
|
||||
const MetadataHeroSettingsKey = "metadata_hero_settings"
|
||||
|
||||
const (
|
||||
MetadataHeroTitle = "title"
|
||||
MetadataHeroRatings = "ratings"
|
||||
MetadataHeroFacts = "facts"
|
||||
MetadataHeroSummary = "summary"
|
||||
MetadataHeroReason = "recommendation_reason"
|
||||
MetadataHeroTime = "time_remaining"
|
||||
)
|
||||
|
||||
var DefaultMetadataHeroContentOrder = []string{
|
||||
MetadataHeroTitle,
|
||||
MetadataHeroRatings,
|
||||
MetadataHeroFacts,
|
||||
MetadataHeroSummary,
|
||||
}
|
||||
|
||||
var MetadataHeroContentOptions = []string{
|
||||
MetadataHeroTitle,
|
||||
MetadataHeroRatings,
|
||||
MetadataHeroFacts,
|
||||
MetadataHeroSummary,
|
||||
MetadataHeroReason,
|
||||
MetadataHeroTime,
|
||||
}
|
||||
|
||||
// MetadataHeroSettings is one presentation decision for the whole household. It lives
|
||||
// outside user preferences because the hero is part of the product's shared composition,
|
||||
// not something that should change when the viewer changes.
|
||||
type MetadataHeroSettings struct {
|
||||
ContentOrder []string `json:"contentOrder"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSettings {
|
||||
seen := map[string]bool{}
|
||||
order := make([]string, 0, len(MetadataHeroContentOptions))
|
||||
for _, entry := range settings.ContentOrder {
|
||||
if seen[entry] || !containsString(MetadataHeroContentOptions, entry) {
|
||||
continue
|
||||
}
|
||||
seen[entry] = true
|
||||
order = append(order, entry)
|
||||
}
|
||||
if len(order) == 0 {
|
||||
order = append([]string(nil), DefaultMetadataHeroContentOrder...)
|
||||
}
|
||||
settings.ContentOrder = order
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *Store) MetadataHeroSettings(ctx context.Context) (MetadataHeroSettings, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, MetadataHeroSettingsKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return NormalizeMetadataHeroSettings(MetadataHeroSettings{}), nil
|
||||
}
|
||||
if err != nil {
|
||||
return MetadataHeroSettings{}, fmt.Errorf("store: read metadata hero settings: %w", err)
|
||||
}
|
||||
var settings MetadataHeroSettings
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return MetadataHeroSettings{}, fmt.Errorf("store: decode metadata hero settings: %w", err)
|
||||
}
|
||||
return NormalizeMetadataHeroSettings(settings), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetMetadataHeroSettings(
|
||||
ctx context.Context, settings MetadataHeroSettings,
|
||||
) (MetadataHeroSettings, error) {
|
||||
settings = NormalizeMetadataHeroSettings(settings)
|
||||
settings.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return MetadataHeroSettings{}, err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
MetadataHeroSettingsKey, string(raw))
|
||||
if err != nil {
|
||||
return MetadataHeroSettings{}, fmt.Errorf("store: write metadata hero settings: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeMetadataHeroSettingsKeepsAnOrderedSubset(t *testing.T) {
|
||||
got := NormalizeMetadataHeroSettings(MetadataHeroSettings{
|
||||
ContentOrder: []string{"summary", "unknown", "title", "summary"},
|
||||
}).ContentOrder
|
||||
want := []string{"summary", "title"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("content order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMetadataHeroSettingsRestoresTheDefault(t *testing.T) {
|
||||
got := NormalizeMetadataHeroSettings(MetadataHeroSettings{}).ContentOrder
|
||||
if !reflect.DeepEqual(got, DefaultMetadataHeroContentOrder) {
|
||||
t.Fatalf("content order = %v, want %v", got, DefaultMetadataHeroContentOrder)
|
||||
}
|
||||
got[0] = "changed"
|
||||
if DefaultMetadataHeroContentOrder[0] != MetadataHeroTitle {
|
||||
t.Fatal("normalising an empty document returned the shared default slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMetadataHeroSettingsAcceptsOptionalContent(t *testing.T) {
|
||||
got := NormalizeMetadataHeroSettings(MetadataHeroSettings{
|
||||
ContentOrder: []string{MetadataHeroTitle, MetadataHeroReason, MetadataHeroTime},
|
||||
}).ContentOrder
|
||||
want := []string{MetadataHeroTitle, MetadataHeroReason, MetadataHeroTime}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("content order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user