This commit is contained in:
ponzischeme89
2026-08-22 08:26:10 +12:00
parent 3ae8ffad64
commit b131932a4e
34 changed files with 10740 additions and 9190 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+2
View File
@@ -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 />} />
+19
View File
@@ -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 {
+8
View File
@@ -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',
+128
View File
@@ -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&nbsp;&nbsp; &nbsp;&nbsp;1h 54m&nbsp;&nbsp; &nbsp;&nbsp;M&nbsp;&nbsp; &nbsp;&nbsp;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>
)}
</>
);
}
+151
View File
@@ -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
View File
@@ -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))
}
} }
+3
View File
@@ -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
+2
View File
@@ -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))
+4
View File
@@ -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.
+2
View File
@@ -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
+4
View File
@@ -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)
}
}
-20
View File
@@ -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)
} }
+3 -4
View File
@@ -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)
} }
} }
+103
View File
@@ -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)
}
}