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"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||||
/>
|
/>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { RequestsPage } from './pages/Requests';
|
|||||||
import { RecommendationsPage } from './pages/Recommendations';
|
import { RecommendationsPage } from './pages/Recommendations';
|
||||||
import { InspectorPage } from './pages/Inspector';
|
import { InspectorPage } from './pages/Inspector';
|
||||||
import { HeroPage } from './pages/Hero';
|
import { HeroPage } from './pages/Hero';
|
||||||
|
import { MetadataHeroPage } from './pages/MetadataHero';
|
||||||
import { FeaturesPage } from './pages/Features';
|
import { FeaturesPage } from './pages/Features';
|
||||||
import { PlaybackPage } from './pages/Playback';
|
import { PlaybackPage } from './pages/Playback';
|
||||||
import { SubtitlesPage } from './pages/Subtitles';
|
import { SubtitlesPage } from './pages/Subtitles';
|
||||||
@@ -81,6 +82,7 @@ export function App() {
|
|||||||
<Route path="inspector" element={<InspectorPage />} />
|
<Route path="inspector" element={<InspectorPage />} />
|
||||||
|
|
||||||
<Route path="hero" element={<HeroPage />} />
|
<Route path="hero" element={<HeroPage />} />
|
||||||
|
<Route path="metadata-hero" element={<MetadataHeroPage />} />
|
||||||
<Route path="features" element={<FeaturesPage />} />
|
<Route path="features" element={<FeaturesPage />} />
|
||||||
<Route path="playback" element={<PlaybackPage />} />
|
<Route path="playback" element={<PlaybackPage />} />
|
||||||
<Route path="subtitles" element={<SubtitlesPage />} />
|
<Route path="subtitles" element={<SubtitlesPage />} />
|
||||||
|
|||||||
@@ -779,6 +779,25 @@ export interface GatewaySettingsResponse {
|
|||||||
version: string;
|
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
|
/** 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. */
|
* rather than the delivery, which is what makes a repeated webhook one row. */
|
||||||
export interface IngestJob {
|
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.',
|
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
|
||||||
icon: 'star',
|
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',
|
id: 'recommendations',
|
||||||
path: '/admin/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;
|
font-size: 12.5px;
|
||||||
color: var(--muted);
|
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?)
|
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
?.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 =
|
fun buildConfigString(value: String): String =
|
||||||
"\"" + value
|
"\"" + value
|
||||||
.replace("\\", "\\\\")
|
.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.
|
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
|
||||||
val changelogText = rootProject.file("CHANGELOG.md").readText()
|
val changelogText = rootProject.file("CHANGELOG.md").readText()
|
||||||
val gplLicenseText = rootProject.file("LICENSE").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
|
val defaultVersionName = "0.3.00"
|
||||||
// source tree. Local builds keep using the checked-in default.
|
|
||||||
val defaultVersionName = "0.2.99"
|
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
@@ -128,9 +114,6 @@ extensions.configure<ApplicationExtension> {
|
|||||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||||
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
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))
|
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class MaintenanceMonitor(
|
|||||||
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
||||||
|
|
||||||
private val _preferencesRevision = MutableStateFlow(0L)
|
private val _preferencesRevision = MutableStateFlow(0L)
|
||||||
|
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||||
private val _heroRevision = MutableStateFlow("")
|
private val _heroRevision = MutableStateFlow("")
|
||||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||||
@@ -131,6 +132,9 @@ class MaintenanceMonitor(
|
|||||||
*/
|
*/
|
||||||
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
|
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
|
* 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
|
* 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.
|
// clearing it here would fight that loop for the same flow.
|
||||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
|
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||||
_theme.value = GatewayThemeStatus()
|
_theme.value = GatewayThemeStatus()
|
||||||
_heroRevision.value = ""
|
_heroRevision.value = ""
|
||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
_genreBrowserEnabled.value = false
|
_genreBrowserEnabled.value = false
|
||||||
_tvCalendarEnabled.value = false
|
_tvCalendarEnabled.value = false
|
||||||
@@ -314,6 +319,10 @@ class MaintenanceMonitor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
_preferencesRevision.value = status.preferencesRevision
|
_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
|
_theme.value = status.theme
|
||||||
_heroRevision.value = status.hero.revision
|
_heroRevision.value = status.hero.revision
|
||||||
_installPermissionPrompt.value =
|
_installPermissionPrompt.value =
|
||||||
@@ -357,6 +366,7 @@ class MaintenanceMonitor(
|
|||||||
_compatibility.value = null
|
_compatibility.value = null
|
||||||
_embyOutage.value = null
|
_embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
|
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||||
_theme.value = GatewayThemeStatus()
|
_theme.value = GatewayThemeStatus()
|
||||||
_heroRevision.value = ""
|
_heroRevision.value = ""
|
||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
@@ -461,6 +471,10 @@ class MaintenanceMonitor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 POLL_INTERVAL_MS = 10_000L
|
||||||
internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
|
internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
|
||||||
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
|
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
|
||||||
|
|||||||
@@ -242,8 +242,6 @@ internal object SettingsMigrationLogic {
|
|||||||
booleanPreferencesKey("show_home_card_metadata")
|
booleanPreferencesKey("show_home_card_metadata")
|
||||||
] ?: true,
|
] ?: true,
|
||||||
showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: 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,
|
hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false,
|
||||||
homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
|
homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
|
||||||
homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
|
homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
|
||||||
@@ -365,8 +363,6 @@ data class Settings(
|
|||||||
val showHomeCardMetadata: Boolean = true,
|
val showHomeCardMetadata: Boolean = true,
|
||||||
/** Show third-party ratings on browse and detail surfaces. Profile-specific. */
|
/** Show third-party ratings on browse and detail surfaces. Profile-specific. */
|
||||||
val showRatingsStrip: Boolean = true,
|
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. */
|
/** Hide fully watched movies from browse rows and hero selections. */
|
||||||
val hideWatchedMovies: Boolean = false,
|
val hideWatchedMovies: Boolean = false,
|
||||||
/** Newline-separated server row ids. These are profile-specific home customisations. */
|
/** 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 homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||||
val showHomeCardMetadata: Boolean = true,
|
val showHomeCardMetadata: Boolean = true,
|
||||||
val showRatingsStrip: Boolean = true,
|
val showRatingsStrip: Boolean = true,
|
||||||
val metadataHeroContentOrder: String = UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
|
||||||
val hideWatchedMovies: Boolean = false,
|
val hideWatchedMovies: Boolean = false,
|
||||||
val homeRowOrder: String = "",
|
val homeRowOrder: String = "",
|
||||||
val homePinnedRows: String = "",
|
val homePinnedRows: String = "",
|
||||||
@@ -595,7 +590,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
|
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
|
||||||
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
||||||
val SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip")
|
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 HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
|
||||||
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
|
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
|
||||||
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
|
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.
|
* 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
|
* 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
|
* edit, and this touches the viewer's full document plus the profiles blob — doing it
|
||||||
* individual setters would be eighteen rewrites for one sync, on a TV that has just
|
* through the individual setters would be many rewrites for one sync, on a TV that has just
|
||||||
* started up.
|
* started up.
|
||||||
*
|
*
|
||||||
* The revision is stored in the same edit as the values it describes. If they could
|
* 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.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
|
||||||
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
|
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
|
||||||
store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip
|
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.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
|
||||||
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
|
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
|
||||||
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
|
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
|
||||||
@@ -895,7 +888,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
homeArtworkStyle = preferences.homeArtworkStyle,
|
homeArtworkStyle = preferences.homeArtworkStyle,
|
||||||
showHomeCardMetadata = preferences.showHomeCardMetadata,
|
showHomeCardMetadata = preferences.showHomeCardMetadata,
|
||||||
showRatingsStrip = preferences.showRatingsStrip,
|
showRatingsStrip = preferences.showRatingsStrip,
|
||||||
metadataHeroContentOrder = preferences.metadataHeroContentOrder.joinToString("\n"),
|
|
||||||
hideWatchedMovies = preferences.hideWatchedMovies,
|
hideWatchedMovies = preferences.hideWatchedMovies,
|
||||||
showTitleLogo = preferences.showTitleLogo,
|
showTitleLogo = preferences.showTitleLogo,
|
||||||
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
|
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
|
||||||
@@ -1356,8 +1348,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||||
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
|
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
|
||||||
showRatingsStrip = previous?.showRatingsStrip ?: true,
|
showRatingsStrip = previous?.showRatingsStrip ?: true,
|
||||||
metadataHeroContentOrder = previous?.metadataHeroContentOrder
|
|
||||||
?: UserPreferences.DEFAULT_METADATA_HERO_CONTENT_ORDER.joinToString("\n"),
|
|
||||||
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
|
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
|
||||||
homeRowOrder = previous?.homeRowOrder.orEmpty(),
|
homeRowOrder = previous?.homeRowOrder.orEmpty(),
|
||||||
homePinnedRows = previous?.homePinnedRows.orEmpty(),
|
homePinnedRows = previous?.homePinnedRows.orEmpty(),
|
||||||
@@ -1536,7 +1526,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
||||||
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
|
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
|
||||||
preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
|
preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
|
||||||
preferences[Keys.METADATA_HERO_CONTENT_ORDER] = profile.metadataHeroContentOrder
|
|
||||||
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
|
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
|
||||||
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
|
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
|
||||||
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
|
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
|
||||||
@@ -1580,8 +1569,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||||
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: 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,
|
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||||
@@ -1650,8 +1637,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||||
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: 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,
|
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ data class UserPreferences(
|
|||||||
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||||
val showHomeCardMetadata: Boolean = true,
|
val showHomeCardMetadata: Boolean = true,
|
||||||
val showRatingsStrip: 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 hideWatchedMovies: Boolean = false,
|
||||||
val showTitleLogo: Boolean = true,
|
val showTitleLogo: Boolean = true,
|
||||||
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||||
@@ -75,7 +73,6 @@ data class UserPreferences(
|
|||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
|
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,
|
homeArtworkStyle = homeArtworkStyle,
|
||||||
showHomeCardMetadata = showHomeCardMetadata,
|
showHomeCardMetadata = showHomeCardMetadata,
|
||||||
showRatingsStrip = showRatingsStrip,
|
showRatingsStrip = showRatingsStrip,
|
||||||
metadataHeroContentOrder = metadataHeroContentOrder.decodeLineList(),
|
|
||||||
hideWatchedMovies = hideWatchedMovies,
|
hideWatchedMovies = hideWatchedMovies,
|
||||||
showTitleLogo = showTitleLogo,
|
showTitleLogo = showTitleLogo,
|
||||||
welcomeQuoteStyle = welcomeQuoteStyle,
|
welcomeQuoteStyle = welcomeQuoteStyle,
|
||||||
@@ -138,9 +134,6 @@ fun decodeUserPreferences(
|
|||||||
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
|
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
|
||||||
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
|
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
|
||||||
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
|
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
|
||||||
metadataHeroContentOrder = json.stringList(
|
|
||||||
"metadataHeroContentOrder", fallback.metadataHeroContentOrder,
|
|
||||||
),
|
|
||||||
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
|
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
|
||||||
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
|
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
|
||||||
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
|
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
|
||||||
@@ -176,7 +169,6 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
|
|||||||
put("homeArtworkStyle", homeArtworkStyle)
|
put("homeArtworkStyle", homeArtworkStyle)
|
||||||
put("showHomeCardMetadata", showHomeCardMetadata)
|
put("showHomeCardMetadata", showHomeCardMetadata)
|
||||||
put("showRatingsStrip", showRatingsStrip)
|
put("showRatingsStrip", showRatingsStrip)
|
||||||
putJsonArray("metadataHeroContentOrder") { metadataHeroContentOrder.forEach { add(JsonPrimitive(it)) } }
|
|
||||||
put("hideWatchedMovies", hideWatchedMovies)
|
put("hideWatchedMovies", hideWatchedMovies)
|
||||||
put("showTitleLogo", showTitleLogo)
|
put("showTitleLogo", showTitleLogo)
|
||||||
put("welcomeQuoteStyle", welcomeQuoteStyle)
|
put("welcomeQuoteStyle", welcomeQuoteStyle)
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ data class GatewayServiceStatus(
|
|||||||
* on the poll the app is already making.
|
* on the poll the app is already making.
|
||||||
*/
|
*/
|
||||||
val preferencesRevision: Long = 0,
|
val preferencesRevision: Long = 0,
|
||||||
|
/** 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
|
* 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
|
* 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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.Settings
|
import com.ponzischeme89.memby.data.Settings
|
||||||
import com.ponzischeme89.memby.data.decodeLineList
|
|
||||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||||
@@ -223,6 +222,8 @@ internal fun HomeScreen(
|
|||||||
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
|
val favoriteChanges by homeViewModel.favoriteChanges.collectAsStateWithLifecycle()
|
||||||
val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
|
val playedChanges by homeViewModel.playedChanges.collectAsStateWithLifecycle()
|
||||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||||
|
val metadataHeroContentOrder by
|
||||||
|
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
||||||
@@ -1430,7 +1431,7 @@ internal fun HomeScreen(
|
|||||||
} else {
|
} else {
|
||||||
FocusedHomeMetadata(
|
FocusedHomeMetadata(
|
||||||
homeViewModel = homeViewModel,
|
homeViewModel = homeViewModel,
|
||||||
metadataHeroContentOrder = settings.metadataHeroContentOrder.decodeLineList(),
|
metadataHeroContentOrder = metadataHeroContentOrder,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(metadataHeight)
|
.height(metadataHeight)
|
||||||
// LazyColumn is drawn later as a sibling. Keep the hero
|
// 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.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
@@ -136,12 +137,19 @@ private val MutedText: Color get() = MembyMutedText
|
|||||||
private val QuietText: Color get() = MembyQuietText
|
private val QuietText: Color get() = MembyQuietText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
fun MediaBadge(
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fontSize: TextUnit = 11.sp,
|
||||||
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = MembyOnSurface,
|
color = MembyOnSurface,
|
||||||
fontSize = 11.sp,
|
fontSize = fontSize,
|
||||||
|
lineHeight = lineHeight,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
|
.border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp))
|
||||||
.background(Color.Black.copy(alpha = 0.22f), 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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
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.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.heightIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.weight
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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 BACKDROP_SETTLE_DELAY = 240.milliseconds
|
||||||
private val MetadataHeroLogoMaxWidth = 360.dp
|
private val MetadataHeroLogoMaxWidth = 360.dp
|
||||||
private val MetadataHeroLogoMaxHeight = 72.dp
|
private val MetadataHeroLogoMaxHeight = 72.dp
|
||||||
|
private val MetadataHeroFactsFontSize = 13.sp
|
||||||
|
private val MetadataHeroFactsLineHeight = 18.sp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||||
@@ -229,19 +233,45 @@ private fun MetadataContent(
|
|||||||
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
|
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
MetadataHeroSection.Facts -> {
|
MetadataHeroSection.Facts -> {
|
||||||
// One information block between ratings and plot: catalogue facts on
|
// Catalogue facts and the outlined file badges are one metadata line.
|
||||||
// the first line, then the file's picture and sound options beneath.
|
// Badges are measured first so Stereo/5.1 and picture formats remain
|
||||||
if (facts.isNotEmpty()) {
|
// visible; a long genre list gives way through ellipsis before the
|
||||||
Text(facts.joinToString(FactSeparator), color = MutedText, fontSize = 13.sp,
|
// file information wraps or escapes the hero.
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
if (facts.isNotEmpty() || badges.isNotEmpty()) {
|
||||||
}
|
Row(
|
||||||
item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
RecommendationReasonCaption(recommendationReasonSummary(it), Modifier.fillMaxWidth())
|
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||||
}
|
modifier = Modifier.fillMaxWidth().clipToBounds(),
|
||||||
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
) {
|
||||||
badges.forEach { MediaBadge(it) }
|
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(
|
MetadataHeroSection.Summary -> Text(
|
||||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
||||||
@@ -259,7 +289,10 @@ private object MetadataHeroSection {
|
|||||||
const val Ratings = "ratings"
|
const val Ratings = "ratings"
|
||||||
const val Facts = "facts"
|
const val Facts = "facts"
|
||||||
const val Summary = "summary"
|
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> =
|
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 }
|
.map { if (it == "genres") MetadataHeroSection.Facts else it }
|
||||||
.filter { it in MetadataHeroSection.all }
|
.filter { it in MetadataHeroSection.all }
|
||||||
.distinct()
|
.distinct()
|
||||||
.ifEmpty { MetadataHeroSection.all }
|
.ifEmpty { MetadataHeroSection.defaults }
|
||||||
|
|
||||||
/** Catalogue information shown as one block between the ratings strip and the plot. */
|
/** Catalogue information shown as one block between the ratings strip and the plot. */
|
||||||
internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
|
internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
|
||||||
@@ -385,21 +418,10 @@ private fun ScheduleMetadataContent(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MetadataStatus(item: BaseItem) {
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
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) {
|
if (item.userData?.played == true) {
|
||||||
Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
|
Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
|
||||||
Text("Watched", color = MutedText, fontSize = 13.sp)
|
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 com.ponzischeme89.memby.ui.theme.mark
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -15,13 +13,9 @@ import androidx.compose.foundation.focusGroup
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.layout.layout
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.type
|
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.res.painterResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.onLongClick
|
import androidx.compose.ui.semantics.onLongClick
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
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.R
|
||||||
import com.ponzischeme89.memby.BuildConfig
|
import com.ponzischeme89.memby.BuildConfig
|
||||||
import com.ponzischeme89.memby.data.EmbyProfile
|
import com.ponzischeme89.memby.data.EmbyProfile
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
|
||||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
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.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.MembyChipCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
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.MembyPanelCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
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.BundledRemoteConfig
|
||||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||||
import java.util.Locale
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class UserPreferencesTest {
|
|||||||
homeArtworkStyle = "poster",
|
homeArtworkStyle = "poster",
|
||||||
showHomeCardMetadata = false,
|
showHomeCardMetadata = false,
|
||||||
showRatingsStrip = false,
|
showRatingsStrip = false,
|
||||||
metadataHeroContentOrder = listOf("title", "ratings", "facts", "summary", "genres"),
|
|
||||||
hideWatchedMovies = true,
|
hideWatchedMovies = true,
|
||||||
showTitleLogo = false,
|
showTitleLogo = false,
|
||||||
welcomeQuoteStyle = "homicidal",
|
welcomeQuoteStyle = "homicidal",
|
||||||
@@ -143,7 +142,6 @@ class UserPreferencesTest {
|
|||||||
homeArtworkStyle = "backdrop",
|
homeArtworkStyle = "backdrop",
|
||||||
showHomeCardMetadata = false,
|
showHomeCardMetadata = false,
|
||||||
showRatingsStrip = false,
|
showRatingsStrip = false,
|
||||||
metadataHeroContentOrder = "title\nratings\nfacts\ngenres\nsummary",
|
|
||||||
hideWatchedMovies = true,
|
hideWatchedMovies = true,
|
||||||
showTitleLogo = false,
|
showTitleLogo = false,
|
||||||
welcomeQuoteStyle = "positive",
|
welcomeQuoteStyle = "positive",
|
||||||
@@ -164,7 +162,6 @@ class UserPreferencesTest {
|
|||||||
homeArtworkStyle = "backdrop",
|
homeArtworkStyle = "backdrop",
|
||||||
showHomeCardMetadata = false,
|
showHomeCardMetadata = false,
|
||||||
showRatingsStrip = false,
|
showRatingsStrip = false,
|
||||||
metadataHeroContentOrder = listOf("title", "ratings", "facts", "genres", "summary"),
|
|
||||||
hideWatchedMovies = true,
|
hideWatchedMovies = true,
|
||||||
showTitleLogo = false,
|
showTitleLogo = false,
|
||||||
welcomeQuoteStyle = "positive",
|
welcomeQuoteStyle = "positive",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
|
|||||||
|
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.MediaStream
|
import com.ponzischeme89.memby.data.model.MediaStream
|
||||||
|
import com.ponzischeme89.memby.data.model.UserItemData
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -32,7 +33,17 @@ class MetadataHeroOrderTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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(
|
val item = BaseItem(
|
||||||
id = "film",
|
id = "film",
|
||||||
productionYear = 2026,
|
productionYear = 2026,
|
||||||
@@ -45,4 +56,15 @@ class MetadataHeroOrderTest {
|
|||||||
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
|
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
|
||||||
assertEquals(listOf("STEREO"), mediaBadges(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 {
|
if err := server.LoadGatewaySettings(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := server.LoadMetadataHeroSettings(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Every long-running worker below is started through runtimestats.Go rather than with
|
// 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
|
// 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
|
// 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("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
||||||
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||||
mux.Handle("POST /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/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||||
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
||||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
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 {
|
if _, ok := body["preferencesRevision"]; !ok {
|
||||||
t.Fatalf("status response carried no preferences revision: %v", body)
|
t.Fatalf("status response carried no preferences revision: %v", body)
|
||||||
}
|
}
|
||||||
|
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
|
// 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
|
// operator's hero change: a television comparing against a missing field would go on
|
||||||
// drawing yesterday's hero until it was next restarted.
|
// drawing yesterday's hero until it was next restarted.
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ type Server struct {
|
|||||||
recommendationBuilds recommendationBuilds
|
recommendationBuilds recommendationBuilds
|
||||||
maintenance maintenanceState
|
maintenance maintenanceState
|
||||||
quietTime quietTimeState
|
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
|
// gatewaySettings is the operator's runtime amendment to what the container was
|
||||||
// started with. deployedLogLevel is remembered beside the live level variable
|
// started with. deployedLogLevel is remembered beside the live level variable
|
||||||
// because clearing an override has to restore something, and the 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
|
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
||||||
// delivery channel for an operator pushing someone's settings.
|
// delivery channel for an operator pushing someone's settings.
|
||||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
||||||
|
// 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
|
// 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
|
// 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
|
// 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.",
|
Description: "Show third-party ratings on browse and detail pages.",
|
||||||
Kind: preferenceToggle, Default: true,
|
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",
|
Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
|
||||||
Description: "Keep fully watched films out of browse rows and the hero.",
|
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 {
|
if selected := stringList(value); len(selected) > 0 {
|
||||||
kept := []string{}
|
kept := []string{}
|
||||||
for _, entry := range selected {
|
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) {
|
if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
|
||||||
kept = append(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{
|
result := normalizePreferences(map[string]any{
|
||||||
"metadataHeroContentOrder": []any{"title", "ratings", "genres", "summary"},
|
"metadataHeroContentOrder": []any{"title", "ratings", "genres", "summary"},
|
||||||
})
|
})
|
||||||
want := []string{"title", "ratings", "facts", "summary"}
|
if _, exists := result["metadataHeroContentOrder"]; exists {
|
||||||
if !reflect.DeepEqual(result["metadataHeroContentOrder"], want) {
|
t.Fatal("the household-wide metadata hero setting remained in a user's preferences")
|
||||||
t.Errorf("metadataHeroContentOrder = %v, want %v", result["metadataHeroContentOrder"], want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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