This commit is contained in:
ponzischeme89
2026-08-22 11:32:50 +12:00
parent b131932a4e
commit f982d391b9
40 changed files with 25594 additions and 9575 deletions
Binary file not shown.
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
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-Db7bWHnD.js"></script> <script type="module" crossorigin src="/admin/assets/index-BQVB9Fvj.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-BS_y3llT.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -789,6 +789,7 @@ export interface MetadataHeroOption {
export interface MetadataHeroSettings { export interface MetadataHeroSettings {
contentOrder: string[]; contentOrder: string[];
timeRemainingColour: 'green' | 'white';
updatedAt?: string; updatedAt?: string;
updatedBy?: string; updatedBy?: string;
} }
+30 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { MetadataHeroSettingsResponse } from '../api/types'; import type { MetadataHeroSettingsResponse } from '../api/types';
import { Banner, Button, Card, Loading, PageHead, Toggle } from '../components/ui'; import { Banner, Button, Card, Loading, PageHead, Segments, Toggle } from '../components/ui';
import { useAction, useQuery } from '../lib/hooks'; import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast'; import { useToast } from '../lib/toast';
@@ -12,12 +12,17 @@ export function MetadataHeroPage() {
const { busy, run } = useAction(); const { busy, run } = useAction();
const { wrap } = useToast(); const { wrap } = useToast();
const [order, setOrder] = useState<string[] | null>(null); const [order, setOrder] = useState<string[] | null>(null);
const [timeRemainingColour, setTimeRemainingColour] = useState<'green' | 'white' | null>(null);
useEffect(() => { useEffect(() => {
if (order === null && query.data) setOrder(query.data.settings.contentOrder); if (order === null && query.data) setOrder(query.data.settings.contentOrder);
}, [order, query.data]); if (timeRemainingColour === null && query.data) {
setTimeRemainingColour(query.data.settings.timeRemainingColour ?? 'green');
}
}, [order, query.data, timeRemainingColour]);
const selected = order ?? DEFAULT_ORDER; const selected = order ?? DEFAULT_ORDER;
const selectedTimeRemainingColour = timeRemainingColour ?? 'green';
const options = query.data?.options ?? []; const options = query.data?.options ?? [];
const displayOptions = [ const displayOptions = [
...selected, ...selected,
@@ -35,12 +40,16 @@ export function MetadataHeroPage() {
}; };
const save = () => run('save', async () => { const save = () => run('save', async () => {
const response = await wrap( const response = await wrap(
() => api.post<MetadataHeroSettingsResponse>('/admin/api/metadata-hero', { contentOrder: selected }), () => api.post<MetadataHeroSettingsResponse>('/admin/api/metadata-hero', {
contentOrder: selected,
timeRemainingColour: selectedTimeRemainingColour,
}),
'Metadata hero saved for every television.', 'Metadata hero saved for every television.',
); );
if (response) { if (response) {
query.set(response); query.set(response);
setOrder(response.settings.contentOrder); setOrder(response.settings.contentOrder);
setTimeRemainingColour(response.settings.timeRemainingColour);
} }
}); });
@@ -62,7 +71,10 @@ export function MetadataHeroPage() {
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}> <Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save metadata hero Save metadata hero
</Button> </Button>
<Button onClick={() => setOrder(DEFAULT_ORDER)}>Restore default order</Button> <Button onClick={() => {
setOrder(DEFAULT_ORDER);
setTimeRemainingColour('green');
}}>Restore defaults</Button>
</> </>
} }
> >
@@ -94,6 +106,18 @@ export function MetadataHeroPage() {
); );
})} })}
</div> </div>
<div className="metadata-hero-colour">
<b>Time-remaining text colour</b>
<p>Choose the caption colour used beside the resume progress bar.</p>
<Segments
value={selectedTimeRemainingColour}
options={[
{ value: 'green', label: 'Emby green' },
{ value: 'white', label: 'White' },
]}
onChange={setTimeRemainingColour}
/>
</div>
</Card> </Card>
<Card <Card
@@ -111,8 +135,8 @@ export function MetadataHeroPage() {
<b>4K</b><b>5.1</b> <b>4K</b><b>5.1</b>
</div>; </div>;
if (section === 'recommendation_reason') return <div className="metadata-preview-reason" key={section}>Because you watched Harbour Lights</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}> if (section === 'time_remaining') return <div className="metadata-preview-progress" data-colour={selectedTimeRemainingColour} key={section}>
<span><i /></span><b>47 MINUTES REMAINING</b> <span><i /></span><b>47 minutes remaining</b>
</div>; </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>; 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; return null;
+12 -1
View File
@@ -3698,6 +3698,16 @@ details summary {
} }
.metadata-hero-order-actions { display: flex; gap: 5px; } .metadata-hero-order-actions { display: flex; gap: 5px; }
.metadata-hero-order-actions button { min-width: 34px; padding-inline: 8px; } .metadata-hero-order-actions button { min-width: 34px; padding-inline: 8px; }
.metadata-hero-colour {
display: grid;
gap: 7px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
}
.metadata-hero-colour > b { font-size: 13.5px; }
.metadata-hero-colour > p { margin: 0; color: var(--muted); font-size: 12.5px; }
.metadata-hero-colour .segments { width: fit-content; }
.metadata-hero-preview { .metadata-hero-preview {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
@@ -3805,7 +3815,8 @@ details summary {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
} }
.metadata-preview-progress b { font-size: inherit; line-height: inherit; } .metadata-preview-progress b { color: var(--accent); font-size: inherit; line-height: inherit; }
.metadata-preview-progress[data-colour="white"] b { color: #fff; }
.metadata-preview-summary { margin: 0; color: #cbd2d7; font-size: 12px; line-height: 1.45; } .metadata-preview-summary { margin: 0; color: #cbd2d7; font-size: 12px; line-height: 1.45; }
@media (max-width: 980px) { @media (max-width: 980px) {
+16 -1
View File
@@ -38,6 +38,15 @@ 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("\\", "\\\\")
@@ -49,8 +58,11 @@ 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)
val defaultVersionName = "0.3.00" val defaultVersionName = "0.3.01"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -114,6 +126,9 @@ 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))
} }
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
private val _preferencesRevision = MutableStateFlow(0L) private val _preferencesRevision = MutableStateFlow(0L)
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER) private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
private val _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)
@@ -134,6 +135,8 @@ class MaintenanceMonitor(
/** One household-wide composition, delivered by the status poll to every viewer. */ /** One household-wide composition, delivered by the status poll to every viewer. */
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow() val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
val metadataHeroTimeRemainingColour: StateFlow<String> =
_metadataHeroTimeRemainingColour.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
@@ -188,18 +191,18 @@ class MaintenanceMonitor(
*/ */
val requestsAllowed: StateFlow<Boolean> = _requestsAllowed.asStateFlow() val requestsAllowed: StateFlow<Boolean> = _requestsAllowed.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */ /** Build reported by the connected gateway, for the Settings rail. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow() val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
/** /**
* The Emby build the gateway is talking to, printed beside the gateway's own on * The Emby build the gateway is talking to, printed beside the gateway's own on
* Settings About. Blank whenever it is not known no probe has answered yet, the * Settings rail. Blank whenever it is not known no probe has answered yet, the
* probe is switched off, or the gateway predates the field and About then prints the * probe is switched off, or the gateway predates the field and the rail then prints the
* gateway's version alone rather than an empty pair of brackets. * gateway's version alone rather than an empty pair of brackets.
* *
* Deliberately *not* cleared when the gateway drops out, unlike [gatewayVersion]. That * Deliberately *not* cleared when the gateway drops out, unlike [gatewayVersion]. That
* one describes a connection that is now gone; this describes a server that is still * one describes a connection that is now gone; this describes a server that is still
* whatever version it was, and About is exactly the page somebody opens when something * whatever version it was, and Settings is exactly where somebody looks when something
* has stopped working. * has stopped working.
*/ */
val embyVersion: StateFlow<String> = _embyVersion.asStateFlow() val embyVersion: StateFlow<String> = _embyVersion.asStateFlow()
@@ -283,8 +286,9 @@ class MaintenanceMonitor(
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 _metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_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
@@ -323,6 +327,12 @@ class MaintenanceMonitor(
.filter { it in METADATA_HERO_CONTENT_OPTIONS } .filter { it in METADATA_HERO_CONTENT_OPTIONS }
.distinct() .distinct()
.ifEmpty { DEFAULT_METADATA_HERO_CONTENT_ORDER } .ifEmpty { DEFAULT_METADATA_HERO_CONTENT_ORDER }
_metadataHeroTimeRemainingColour.value =
status.metadataHeroTimeRemainingColour
.trim()
.lowercase()
.takeIf { it == METADATA_HERO_TIME_COLOUR_WHITE }
?: METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = status.theme _theme.value = status.theme
_heroRevision.value = status.hero.revision _heroRevision.value = status.hero.revision
_installPermissionPrompt.value = _installPermissionPrompt.value =
@@ -339,7 +349,7 @@ class MaintenanceMonitor(
// should not have that fact disappear from the poll. // should not have that fact disappear from the poll.
_embyOutage.value = outageFrom(status.emby) _embyOutage.value = outageFrom(status.emby)
// Beside the outage, and for the same reason: reported even // Beside the outage, and for the same reason: reported even
// during maintenance, since an operator looking at About while // during maintenance, since an operator looking at Settings while
// Memby is down still wants to know what Emby is running. // Memby is down still wants to know what Emby is running.
status.emby.version.takeIf(String::isNotBlank)?.let { status.emby.version.takeIf(String::isNotBlank)?.let {
_embyVersion.value = it _embyVersion.value = it
@@ -367,8 +377,9 @@ class MaintenanceMonitor(
_embyOutage.value = null _embyOutage.value = null
_preferencesRevision.value = 0 _preferencesRevision.value = 0
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER _metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_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
@@ -475,6 +486,8 @@ class MaintenanceMonitor(
listOf("title", "ratings", "facts", "summary") listOf("title", "ratings", "facts", "summary")
internal val METADATA_HERO_CONTENT_OPTIONS = internal val METADATA_HERO_CONTENT_OPTIONS =
DEFAULT_METADATA_HERO_CONTENT_ORDER + listOf("recommendation_reason", "time_remaining") DEFAULT_METADATA_HERO_CONTENT_ORDER + listOf("recommendation_reason", "time_remaining")
internal const val METADATA_HERO_TIME_COLOUR_GREEN = "green"
internal const val METADATA_HERO_TIME_COLOUR_WHITE = "white"
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
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
val preferencesRevision: Long = 0, val preferencesRevision: Long = 0,
/** Household-wide order of the focused metadata hero's content blocks. */ /** Household-wide order of the focused metadata hero's content blocks. */
val metadataHeroContentOrder: List<String> = emptyList(), val metadataHeroContentOrder: List<String> = emptyList(),
/** Household-wide colour for the optional time-remaining caption: green or white. */
val metadataHeroTimeRemainingColour: String = "green",
/** /**
* 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
@@ -317,9 +319,9 @@ data class GatewayEmbyHealth(
val checkedAt: String = "", val checkedAt: String = "",
val retrySeconds: Int = 0, val retrySeconds: Int = 0,
/** /**
* Emby's own version, for the About page. Blank when no probe has answered yet, when the * Emby's own version, for the Settings rail. Blank when no probe has answered yet, when the
* probe is switched off, and on a gateway that predates the field all three of which * probe is switched off, and on a gateway that predates the field all three of which
* the page renders the same way, by printing the gateway's version alone. * rail renders the same way, by printing the gateway's version alone.
*/ */
val version: String = "", val version: String = "",
) { ) {
@@ -284,6 +284,7 @@ internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
internal fun FocusedHomeMetadata( internal fun FocusedHomeMetadata(
homeViewModel: HomeViewModel, homeViewModel: HomeViewModel,
metadataHeroContentOrder: List<String>, metadataHeroContentOrder: List<String>,
metadataHeroTimeRemainingColour: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
@@ -295,6 +296,7 @@ internal fun FocusedHomeMetadata(
item = focusedItem, item = focusedItem,
loading = homeContent.loading.isNotEmpty(), loading = homeContent.loading.isNotEmpty(),
contentOrder = metadataHeroContentOrder, contentOrder = metadataHeroContentOrder,
timeRemainingColour = metadataHeroTimeRemainingColour,
modifier = modifier, modifier = modifier,
) )
} }
@@ -224,6 +224,8 @@ internal fun HomeScreen(
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val metadataHeroContentOrder by val metadataHeroContentOrder by
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle() ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
val metadataHeroTimeRemainingColour by
ServiceLocator.maintenance.metadataHeroTimeRemainingColour.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()
@@ -286,9 +288,6 @@ internal fun HomeScreen(
var quickMenuTrailerAvailable by remember { mutableStateOf(false) } var quickMenuTrailerAvailable by remember { mutableStateOf(false) }
var quickMenuRowId by remember { mutableStateOf<String?>(null) } var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) } var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var rowBrowseTarget by remember { mutableStateOf<HomeRowBrowseTarget?>(null) }
var rowBrowseOriginRowId by remember { mutableStateOf<String?>(null) }
var rowBrowseOriginItemId by remember { mutableStateOf<String?>(null) }
var sectionHeroRows by remember(settings.userId) { var sectionHeroRows by remember(settings.userId) {
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap()) mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
} }
@@ -847,7 +846,6 @@ internal fun HomeScreen(
) )
LaunchedEffect(selectedDestination) { LaunchedEffect(selectedDestination) {
focusedHomeRowId = null focusedHomeRowId = null
rowBrowseTarget = null
} }
LaunchedEffect(selectedDestination, settings.forYouMinutes) { LaunchedEffect(selectedDestination, settings.forYouMinutes) {
if ( if (
@@ -868,7 +866,6 @@ internal fun HomeScreen(
showNotifications -> "notifications" showNotifications -> "notifications"
userSwitcherVisible || showProfiles -> "profiles" userSwitcherVisible || showProfiles -> "profiles"
detailsItem != null -> "details" detailsItem != null -> "details"
rowBrowseTarget != null -> "row_browse"
selectedMyShow != null -> "my_show_details" selectedMyShow != null -> "my_show_details"
else -> selectedDestination.name.lowercase() else -> selectedDestination.name.lowercase()
} }
@@ -941,7 +938,6 @@ internal fun HomeScreen(
navigationExpanded = it navigationExpanded = it
}, },
onDestinationSelected = { destination -> onDestinationSelected = { destination ->
rowBrowseTarget = null
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "navigation", action = "select", category = "navigation", action = "select",
screen = journeyScreen, feature = destination.name.lowercase(), screen = journeyScreen, feature = destination.name.lowercase(),
@@ -1057,53 +1053,6 @@ internal fun HomeScreen(
return@BoxWithConstraints return@BoxWithConstraints
} }
rowBrowseTarget?.let { browseTarget ->
GenreBrowseScreen(
itemType = browseTarget.itemType,
initialCategoryId = browseTarget.categoryId,
favouriteStates = favoriteChanges,
playedStates = playedChanges,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf {
returnRowId == ROW_BROWSE_RESULTS_ID
},
returnFocusRequester = cardReturnFocusRequester,
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = ROW_BROWSE_RESULTS_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
category = "content", action = "open", screen = "row_browse",
feature = "view_all", source = "row_browse_results",
target = "details", itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsItem = item
},
onContentFocused = { navigationExpanded = false },
onClose = {
rowBrowseTarget = null
returnRowId = rowBrowseOriginRowId
returnRowKind = rows.firstOrNull {
it.id == rowBrowseOriginRowId
}?.kind?.name
returnItemId = rowBrowseOriginItemId
scope.launch {
delay(16.milliseconds)
if (rowBrowseOriginItemId != null) {
runCatching { cardReturnFocusRequester.requestFocus() }
} else {
runCatching { contentFocusRequester.requestFocus() }
}
}
},
)
return@BoxWithConstraints
}
if (selectedDestination == BrowseDestination.SEARCH) { if (selectedDestination == BrowseDestination.SEARCH) {
// Remembered, or this list is a fresh instance on every recomposition // Remembered, or this list is a fresh instance on every recomposition
// and the screen re-derives its genre chips each time. // and the screen re-derives its genre chips each time.
@@ -1432,6 +1381,7 @@ internal fun HomeScreen(
FocusedHomeMetadata( FocusedHomeMetadata(
homeViewModel = homeViewModel, homeViewModel = homeViewModel,
metadataHeroContentOrder = metadataHeroContentOrder, metadataHeroContentOrder = metadataHeroContentOrder,
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
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
@@ -1565,7 +1515,6 @@ internal fun HomeScreen(
row.items.take(8).map { it.id }, row.items.take(8).map { it.id },
) )
} }
val browseTarget = homeRowBrowseTarget(row)
MediaRow( MediaRow(
modifier = Modifier, modifier = Modifier,
row = row, row = row,
@@ -1727,24 +1676,6 @@ internal fun HomeScreen(
horizontalState = horizontalStates.getOrPut( horizontalState = horizontalStates.getOrPut(
"${selectedDestination.name}:${row.id}", "${selectedDestination.name}:${row.id}",
) { LazyListState() }, ) { LazyListState() },
viewAllLabel = browseTarget?.actionLabel,
onViewAll = browseTarget?.let { target ->
{
rowBrowseOriginRowId = row.id
rowBrowseOriginItemId = returnItemId.takeIf {
returnRowId == row.id
} ?: row.items.getOrNull(
rowFocusPositions[row.id] ?: 0,
)?.id
rowBrowseTarget = target
homeViewModel.trackJourney(
category = "navigation", action = "open",
screen = selectedDestination.name.lowercase(),
feature = "view_all", source = row.id,
target = "row_browse",
)
}
},
) )
} }
} }
@@ -35,6 +35,7 @@ internal fun MetadataHero(
item: BaseItem?, item: BaseItem?,
loading: Boolean, loading: Boolean,
contentOrder: List<String> = emptyList(), contentOrder: List<String> = emptyList(),
timeRemainingColour: String = "green",
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(
@@ -71,6 +72,7 @@ internal fun MetadataHero(
item = item, item = item,
loading = loading, loading = loading,
contentOrder = contentOrder, contentOrder = contentOrder,
timeRemainingColour = timeRemainingColour,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding( .padding(
@@ -104,7 +104,7 @@ internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
null null
} }
return when { return when {
code != null && cleanName.isNotEmpty() -> "$code - $cleanName" code != null && cleanName.isNotEmpty() -> "$code · $cleanName"
code != null -> code code != null -> code
cleanName.isNotEmpty() -> cleanName cleanName.isNotEmpty() -> cleanName
else -> null else -> null
@@ -16,7 +16,6 @@ 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
@@ -51,7 +50,6 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText
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.ValueSeparator import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -134,6 +132,7 @@ fun MediaMetadataPanel(
item: BaseItem?, item: BaseItem?,
loading: Boolean, loading: Boolean,
contentOrder: List<String>, contentOrder: List<String>,
timeRemainingColour: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
@@ -157,7 +156,13 @@ fun MediaMetadataPanel(
Text("Choose something to watch.", color = MutedText, fontSize = 14.sp) Text("Choose something to watch.", color = MutedText, fontSize = 14.sp)
} }
} }
else -> MetadataContent(item, contentWidth, compactLayout, contentOrder) else -> MetadataContent(
item,
contentWidth,
compactLayout,
contentOrder,
timeRemainingColour,
)
} }
} }
} }
@@ -201,6 +206,7 @@ private fun MetadataContent(
contentWidth: Dp, contentWidth: Dp,
compact: Boolean, compact: Boolean,
contentOrder: List<String>, contentOrder: List<String>,
timeRemainingColour: String,
) { ) {
if (item.isSchedule) { if (item.isSchedule) {
ScheduleMetadataContent(item, contentWidth, compact) ScheduleMetadataContent(item, contentWidth, compact)
@@ -271,7 +277,7 @@ private fun MetadataContent(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
) )
} }
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item) MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item, timeRemainingColour)
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,
@@ -336,13 +342,17 @@ private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, c
fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
} }
} }
if (item.isEpisode && item.seriesName != null) Text( if (item.isEpisode) {
item.episodeCode episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
?.filter { it.isLetterOrDigit() } Text(
?.lowercase(Locale.ROOT) label,
.orEmpty(), color = MutedText,
color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp,
) maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} }
@Composable @Composable
@@ -434,7 +444,7 @@ private fun MetadataStatus(item: BaseItem) {
} }
@Composable @Composable
private fun MetadataTimeRemaining(item: BaseItem) { private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
val position = item.userData?.playbackPositionTicks ?: 0L val position = item.userData?.playbackPositionTicks ?: 0L
val runtime = item.runTimeTicks ?: 0L val runtime = item.runTimeTicks ?: 0L
val minutes = metadataHeroMinutesRemaining(item) ?: return val minutes = metadataHeroMinutesRemaining(item) ?: return
@@ -453,15 +463,19 @@ private fun MetadataTimeRemaining(item: BaseItem) {
) )
} }
Text( Text(
if (minutes == 1) "1 MINUTE REMAINING" else "$minutes MINUTES REMAINING", metadataHeroTimeRemainingLabel(minutes),
color = MutedText, color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
fontSize = 13.sp, fontSize = 13.sp,
lineHeight = 18.sp, lineHeight = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
) )
} }
} }
internal fun metadataHeroTimeRemainingLabel(minutes: Long): String =
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? { internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? {
val position = item.userData?.playbackPositionTicks ?: return null val position = item.userData?.playbackPositionTicks ?: return null
val runtime = item.runTimeTicks ?: return null val runtime = item.runTimeTicks ?: return null
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -22,23 +21,17 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
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.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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -54,8 +47,6 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
@@ -175,20 +166,10 @@ internal fun MediaRow(
density: String = "standard", density: String = "standard",
artworkStyle: String = "automatic", artworkStyle: String = "automatic",
horizontalState: LazyListState? = null, horizontalState: LazyListState? = null,
viewAllLabel: String? = null,
onViewAll: (() -> Unit)? = null,
) { ) {
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val rowState = horizontalState ?: savedRowState val rowState = horizontalState ?: savedRowState
val verticalEntryFocusRequester = remember { FocusRequester() } val verticalEntryFocusRequester = remember { FocusRequester() }
val previousPageFocusRequester = remember { FocusRequester() }
val nextPageFocusRequester = remember { FocusRequester() }
val viewAllFocusRequester = remember { FocusRequester() }
val headerReturnFocusRequester = remember { FocusRequester() }
val pagedCardFocusRequester = remember { FocusRequester() }
var headerReturnItemId by remember(row.id) { mutableStateOf<String?>(null) }
var pagedCardIndex by remember(row.id) { mutableStateOf<Int?>(null) }
var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) }
val requestedEntryIndex = verticalFocusRequest val requestedEntryIndex = verticalFocusRequest
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex ?.itemIndex
@@ -216,39 +197,6 @@ internal fun MediaRow(
} }
currentOnVerticalFocusRequestConsumed(request.requestId) currentOnVerticalFocusRequestConsumed(request.requestId)
} }
LaunchedEffect(pageFocusRequestId) {
val target = pagedCardIndex ?: return@LaunchedEffect
rowState.scrollToItem(target)
// The target can begin outside the composed LazyRow window. Give layout a frame to
// attach it, then keep the transfer bounded so a refreshed row can never trap focus.
repeat(6) {
delay(16.milliseconds)
if (pagedCardFocusRequester.requestFocusIfAttached()) {
return@LaunchedEffect
}
}
}
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
// only the button's enabled/disabled flip recomposes the row header.
val canScrollBack by remember(rowState) {
derivedStateOf { rowState.canScrollBackward }
}
val canScrollForward by remember(rowState) {
derivedStateOf { rowState.canScrollForward }
}
val pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
) 6 else 4
val hasViewAll = viewAllLabel != null && onViewAll != null
val headerReturnTargetId = headerReturnItemId
?.takeIf { focusedId -> row.items.any { it.id == focusedId } }
?: row.items.firstOrNull()?.id
fun requestHeaderFocus(): Boolean = when {
hasViewAll -> viewAllFocusRequester.requestFocusIfAttached()
canScrollBack -> previousPageFocusRequester.requestFocusIfAttached()
canScrollForward -> nextPageFocusRequester.requestFocusIfAttached()
else -> false
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -266,42 +214,6 @@ internal fun MediaRow(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.weight(1f))
if (row.items.isNotEmpty()) {
if (viewAllLabel != null && onViewAll != null) {
ViewAllButton(
label = viewAllLabel,
focusRequester = viewAllFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = onViewAll,
)
Spacer(Modifier.width(8.dp))
}
GalleryJumpButton(
forward = false,
enabled = canScrollBack,
focusRequester = previousPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
pagedCardIndex = target
pageFocusRequestId += 1
},
)
Spacer(Modifier.width(8.dp))
GalleryJumpButton(
forward = true,
enabled = canScrollForward,
focusRequester = nextPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = {
val target = (rowState.firstVisibleItemIndex + pageSize)
.coerceAtMost(row.items.lastIndex)
pagedCardIndex = target
pageFocusRequestId += 1
},
)
}
} }
when { when {
row.items.isEmpty() && row.loading -> { row.items.isEmpty() && row.loading -> {
@@ -369,52 +281,22 @@ internal fun MediaRow(
if (item.id == returnFocusItemId) { if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester) cardModifier = cardModifier.focusRequester(returnFocusRequester)
} }
if (item.id == headerReturnTargetId) {
cardModifier = cardModifier.focusRequester(headerReturnFocusRequester)
}
if (index == requestedEntryIndex) { if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester) cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
} }
if (index == pagedCardIndex) {
cardModifier = cardModifier.focusRequester(pagedCardFocusRequester)
}
cardModifier = cardModifier.onPreviewKeyEvent { event -> cardModifier = cardModifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) { if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
} }
when (event.key) { when (event.key) {
Key.DirectionUp -> { Key.DirectionUp -> {
requestHeaderFocus() || onMoveVertical(index, RowFocusDirection.UP)
onMoveVertical(index, RowFocusDirection.UP)
} }
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN) Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
Key.DirectionLeft -> {
val firstVisible = rowState.layoutInfo.visibleItemsInfo
.firstOrNull()?.index
if (canScrollBack && index == firstVisible) {
previousPageFocusRequester.requestFocusIfAttached()
} else {
false
}
}
Key.DirectionRight -> {
val lastVisible = rowState.layoutInfo.visibleItemsInfo
.lastOrNull()?.index
if (index != lastVisible) {
false
} else if (viewAllLabel != null && onViewAll != null) {
viewAllFocusRequester.requestFocusIfAttached()
} else if (canScrollForward) {
nextPageFocusRequester.requestFocusIfAttached()
} else {
false
}
}
else -> false else -> false
} }
} }
val focused: () -> Unit = { val focused: () -> Unit = {
headerReturnItemId = item.id
onContentFocused() onContentFocused()
// LazyRow already knows the semantic position. Passing it on // LazyRow already knows the semantic position. Passing it on
// avoids searching the row again on every D-pad focus move. // avoids searching the row again on every D-pad focus move.
@@ -453,105 +335,6 @@ internal fun MediaRow(
} }
} }
@Composable
private fun GalleryJumpButton(
forward: Boolean,
enabled: Boolean,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
onFocused = {},
onClick = { if (enabled) onClick() },
onLongClick = null,
contentDescription = if (forward) "Next page" else "Previous page",
modifier = Modifier
.width(42.dp)
.height(36.dp)
.focusRequester(focusRequester)
.focusProperties {
canFocus = enabled
down = downFocusRequester
},
) { focused ->
Box(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(MembyChipCorner))
.background(
when {
focused -> MembyAccent.copy(alpha = 0.34f)
enabled -> Color.White.copy(alpha = 0.08f)
else -> Color.White.copy(alpha = 0.03f)
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(MembyChipCorner),
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (forward) MembyIcon.ChevronRight.mark else MembyIcon.ChevronLeft.mark,
contentDescription = null,
tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f),
modifier = Modifier.size(23.dp),
)
}
}
}
@Composable
private fun ViewAllButton(
label: String,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
onFocused = {},
onClick = onClick,
contentDescription = label,
modifier = Modifier
.height(36.dp)
.focusRequester(focusRequester)
.focusProperties { down = downFocusRequester },
) { focused ->
Row(
modifier = Modifier
.height(36.dp)
.clip(RoundedCornerShape(MembyChipCorner))
.background(
if (focused) MembyAccent.copy(alpha = 0.34f)
else Color.White.copy(alpha = 0.08f),
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(MembyChipCorner),
)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
Icon(
imageVector = MembyIcon.ChevronRight.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(17.dp),
)
}
}
}
private fun FocusRequester.requestFocusIfAttached(): Boolean = private fun FocusRequester.requestFocusIfAttached(): Boolean =
runCatching { requestFocus() }.getOrDefault(false) runCatching { requestFocus() }.getOrDefault(false)
@@ -1,59 +0,0 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID
import com.ponzischeme89.memby.ui.genre.genreCategoryTabs
internal const val ROW_BROWSE_RESULTS_ID = "row-browse-results"
/** The existing paged catalogue screen a home shelf can expand into. */
internal data class HomeRowBrowseTarget(
val categoryId: String,
val itemType: String,
val actionLabel: String,
)
/**
* Resolves only shelves whose complete catalogue query is known on both backend paths.
*
* Recommendation and studio shelves deliberately return null: the horizontal row is a
* ranked answer, while the catalogue browser can currently page only media types and
* genres. Labelling an unfiltered library as "all Pixar" would be worse than omitting the
* action until that filter exists.
*/
internal fun homeRowBrowseTarget(row: HomeBrowseRow): HomeRowBrowseTarget? {
if (row.id == "latest-movies") {
return HomeRowBrowseTarget(
categoryId = ALL_MEDIA_CATEGORY_ID,
itemType = "Movie",
actionLabel = "View All",
)
}
val itemType = when {
row.id.startsWith("curated:movies:genre:") -> "Movie"
row.id.startsWith("curated:shows:genre:") -> "Series"
row.id.startsWith("curated:") && row.id.endsWith("-shows") -> "Series"
else -> return null
}
val rowLabel = row.title
.removeSuffix(" TV Shows")
.removeSuffix(" Shows")
.removeSuffix(" Movies")
.trim()
val normalisedLabel = rowLabel.normalisedBrowseLabel()
val category = genreCategoryTabs(itemType).firstOrNull { candidate ->
candidate.id != ALL_MEDIA_CATEGORY_ID && (
candidate.label.normalisedBrowseLabel() == normalisedLabel ||
candidate.genres.any { it.normalisedBrowseLabel() == normalisedLabel }
)
} ?: return null
return HomeRowBrowseTarget(
categoryId = category.id,
itemType = itemType,
actionLabel = "View All ${category.label}",
)
}
private fun String.normalisedBrowseLabel(): String =
lowercase().filter(Char::isLetterOrDigit)
@@ -195,7 +195,6 @@ internal enum class SettingsPage(
// its download URL with it. // its download URL with it.
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices), DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage), STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
ABOUT("About", "Version/changelog", MembyIcon.Info),
} }
// Black, and one lit thing at a time. // Black, and one lit thing at a time.
@@ -266,7 +265,6 @@ internal data class SettingsPanelState(
val installedVersion: String = "", val installedVersion: String = "",
val gatewayVersion: String = "", val gatewayVersion: String = "",
val embyVersion: String = "", val embyVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(), val devices: List<GatewayDevice> = emptyList(),
val devicesLoading: Boolean = false, val devicesLoading: Boolean = false,
val devicesError: String? = null, val devicesError: String? = null,
@@ -337,8 +335,8 @@ fun SettingsSheet(
withContext(NonCancellable) { block() } withContext(NonCancellable) { block() }
} }
} }
// Kept only for the version this TV is running, which About prints. Nothing here checks // Kept only for the version this TV is running, which the Settings rail prints. Nothing
// for an update: see the note on SettingsPage. // here checks for an update: see the note on SettingsPage.
val checker = remember { UpdateChecker(context) } val checker = remember { UpdateChecker(context) }
// Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects // Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects
// default chips before DataStore emits; a viewer could press "Automatic" in that gap // default chips before DataStore emits; a viewer could press "Automatic" in that gap
@@ -804,6 +802,9 @@ internal fun SettingsPanelContent(
SettingsSecondaryRail( SettingsSecondaryRail(
selected = state.selectedPage, selected = state.selectedPage,
onSelected = actions.onPageSelected, onSelected = actions.onPageSelected,
installedVersion = state.installedVersion,
gatewayVersion = state.gatewayVersion,
embyVersion = state.embyVersion,
firstFocusRequester = firstFocusRequester, firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester, contentFocusRequester = contentFocusRequester,
@@ -1104,43 +1105,6 @@ internal fun SettingsPanelContent(
SettingsNotice("Stored artwork cleared.", positive = true) SettingsNotice("Stored artwork cleared.", positive = true)
} }
} }
SettingsPage.ABOUT -> SettingsGroup {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
"Version",
color = TextPrimary,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
color = TextSecondary,
fontSize = 12.sp,
)
}
Text(
"v${state.installedVersion}",
color = EmbyGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
SettingDivider()
VersionRow(
label = "Gateway server version",
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
)
}
}
if (state.selectedPage == SettingsPage.ABOUT) {
VersionHistorySection(
releases = state.releaseHistory,
installedVersion = state.installedVersion,
)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -1148,7 +1112,7 @@ internal fun SettingsPanelContent(
} }
/** /**
* What the About page prints beside "Memby gateway" the gateway's own build, with the Emby * What the Settings rail prints beside "Memby Server" the gateway's own build, with the Emby
* it is talking to in brackets after it: `0.1.50 (4.10.0.21)`. * it is talking to in brackets after it: `0.1.50 (4.10.0.21)`.
* *
* The two are one row rather than two because they are one fact: this gateway, against that * The two are one row rather than two because they are one fact: this gateway, against that
@@ -1365,6 +1329,9 @@ private const val SETTINGS_PAGE_SETTLE_MS = 130L
private fun SettingsSecondaryRail( private fun SettingsSecondaryRail(
selected: SettingsPage, selected: SettingsPage,
onSelected: (SettingsPage) -> Unit, onSelected: (SettingsPage) -> Unit,
installedVersion: String,
gatewayVersion: String,
embyVersion: String,
firstFocusRequester: FocusRequester?, firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?, navigationFocusRequester: FocusRequester?,
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
@@ -1532,6 +1499,24 @@ private fun SettingsSecondaryRail(
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Column(
modifier = Modifier.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Memby v${installedVersion.ifBlank { "—" }}",
color = TextSecondary,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"Memby Server ${gatewayVersionLabel(gatewayVersion, embyVersion)}",
color = TextQuiet,
fontSize = 10.sp,
lineHeight = 14.sp,
)
}
Spacer(Modifier.height(8.dp))
Text( Text(
"↑ ↓ moves between pages", "↑ ↓ moves between pages",
color = TextQuiet, color = TextQuiet,
@@ -1557,9 +1542,8 @@ private fun SettingsHeader(page: SettingsPage) {
// already inside Memby on a television, so it named the one thing nobody could be in // already inside Memby on a television, so it named the one thing nobody could be in
// doubt about while taking the vertical space the settings themselves want. // doubt about while taking the vertical space the settings themselves want.
// //
// No version in the corner either, for the same reason it lost the eyebrow: it was // Version details live beneath the navigation in the rail, where they remain visible
// on all seven pages to answer a question asked on two of them, where Updates prints // without taking a page of their own.
// it as "On this TV" and About prints it beside the app's own name.
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold) Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text( Text(
@@ -1590,7 +1574,7 @@ private fun VersionRow(label: String, version: String) {
/** /**
* A run of settings on the black canvas. * A run of settings on the black canvas.
* *
* There is no card and no icon chip. Every page but About holds exactly one of these, and * There is no card and no icon chip. Every page holds exactly one of these, and
* the header above it already names the page a titled box inside a titled page said the * the header above it already names the page a titled box inside a titled page said the
* same word twice and cost two nested surfaces to do it. [label] exists only for the pages * same word twice and cost two nested surfaces to do it. [label] exists only for the pages
* that genuinely have two groups, and it is a quiet caption rather than a second heading. * that genuinely have two groups, and it is a quiet caption rather than a second heading.
@@ -1885,113 +1869,6 @@ private fun SettingsNotice(text: String, positive: Boolean) {
) )
} }
/**
* The release history, as a list of collapsed releases that open in place. A remote has
* no scrollbar to aim at, so the whole changelog rendered flat would be a very long blind
* scroll; one focusable row per release makes Down mean "next release" until the viewer
* asks for a particular one. The newest is open on arrival because that is the one being
* looked for.
*/
@Composable
private fun VersionHistorySection(
releases: List<ReleaseNote>,
installedVersion: String,
) {
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
mutableStateOf(releases.firstOrNull()?.version)
}
SettingsGroup(label = "Changelog") {
if (releases.isEmpty()) {
SettingsNotice("No release history shipped with this build.", positive = false)
return@SettingsGroup
}
releases.forEachIndexed { index, release ->
if (index > 0) SettingDivider()
ReleaseHistoryRow(
release = release,
installed = release.version == installedVersion,
expanded = release.version == expandedVersion,
onToggle = {
expandedVersion = if (expandedVersion == release.version) null else release.version
},
)
}
}
}
@Composable
private fun ReleaseHistoryRow(
release: ReleaseNote,
installed: Boolean,
expanded: Boolean,
onToggle: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) RowFocused else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onToggle)
.testTag("settings-release-${release.version}")
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"v${release.version}",
color = if (installed) EmbyGreen else TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
if (installed) {
Text(
"INSTALLED",
color = EmbyGreen,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
)
}
Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let {
Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
color = if (focused) MembyAccentInk else EmbyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
.padding(horizontal = 11.dp, vertical = 7.dp),
)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
release.changes.forEach { change ->
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text("", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(change, color = TextSecondary, fontSize = 13.sp, lineHeight = 19.sp)
}
}
}
}
}
}
@Composable @Composable
private fun SettingDivider() { private fun SettingDivider() {
Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline)) Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline))
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.Backspace import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.automirrored.filled.HelpOutline import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
@@ -56,7 +57,6 @@ import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistRemove import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
@@ -111,7 +111,7 @@ object MaterialIconPack {
MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder }, MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder },
MembyIcon.Bookmark to { Icons.Default.Bookmark }, MembyIcon.Bookmark to { Icons.Default.Bookmark },
MembyIcon.BookmarkOutline to { Icons.Default.BookmarkBorder }, MembyIcon.BookmarkOutline to { Icons.Default.BookmarkBorder },
MembyIcon.PlaylistAdd to { Icons.Default.PlaylistAdd }, MembyIcon.PlaylistAdd to { Icons.AutoMirrored.Filled.PlaylistAdd },
MembyIcon.PlaylistRemove to { Icons.Default.PlaylistRemove }, MembyIcon.PlaylistRemove to { Icons.Default.PlaylistRemove },
MembyIcon.HideWatched to { Icons.Default.VisibilityOff }, MembyIcon.HideWatched to { Icons.Default.VisibilityOff },
MembyIcon.Check to { Icons.Default.Check }, MembyIcon.Check to { Icons.Default.Check },
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.update package com.ponzischeme89.memby.update
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -54,6 +55,7 @@ object RequiredUpdateSignal {
* verdict that says this build is fine is exactly the evidence that the refusal is * verdict that says this build is fine is exactly the evidence that the refusal is
* spent. * spent.
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
fun clear() { fun clear() {
_required.resetReplayCache() _required.resetReplayCache()
} }
@@ -146,13 +146,14 @@ class GatewayPayloadTest {
@Test @Test
fun `decodes explicit client server protocol mismatch`() { fun `decodes explicit client server protocol mismatch`() {
val status = json.decodeFromString<GatewayServiceStatus>( val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1}""", """{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1,"metadataHeroTimeRemainingColour":"white"}""",
) )
assertEquals(false, status.compatible) assertEquals(false, status.compatible)
assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage) assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
assertEquals("0.1.29", status.gatewayVersion) assertEquals("0.1.29", status.gatewayVersion)
assertEquals(1, status.serverProtocol) assertEquals(1, status.serverProtocol)
assertEquals("white", status.metadataHeroTimeRemainingColour)
} }
@Test @Test
@@ -1,61 +0,0 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HomeRowBrowseTest {
@Test
fun `recent releases opens the complete movie catalogue`() {
val target = homeRowBrowseTarget(row("latest-movies", "Recent New Releases", MediaRowKind.MOVIES))
assertEquals("all", target?.categoryId)
assertEquals("Movie", target?.itemType)
assertEquals("View All", target?.actionLabel)
}
@Test
fun `movie and show genre rows open their matching paged catalogue`() {
val crime = homeRowBrowseTarget(
row("curated:movies:genre:crime", "Crime Movies", MediaRowKind.MOVIES),
)
val drama = homeRowBrowseTarget(
row("curated:shows:genre:drama", "Drama Shows", MediaRowKind.SHOWS),
)
assertEquals("crime", crime?.categoryId)
assertEquals("Movie", crime?.itemType)
assertEquals("View All Crime", crime?.actionLabel)
assertEquals("drama", drama?.categoryId)
assertEquals("Series", drama?.itemType)
assertEquals("View All Drama", drama?.actionLabel)
}
@Test
fun `legacy show genres remain browsable`() {
val target = homeRowBrowseTarget(
row("curated:comedy-shows", "Comedy TV Shows", MediaRowKind.SHOWS),
)
assertEquals("comedy", target?.categoryId)
assertEquals("Series", target?.itemType)
}
@Test
fun `ranked and studio rows do not claim an unrelated complete catalogue`() {
assertNull(homeRowBrowseTarget(row("recommended", "Recommended", MediaRowKind.MOVIES)))
assertNull(
homeRowBrowseTarget(
row("curated:movies:studio:pixar", "More from Pixar", MediaRowKind.MOVIES),
),
)
}
private fun row(id: String, title: String, kind: MediaRowKind) = HomeBrowseRow(
id = id,
title = title,
items = emptyList(),
kind = kind,
emptyMessage = "",
)
}
@@ -1,86 +0,0 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class HomeRowFocusTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `row action returns focus to the card that opened it`() {
compose.setContent {
PreviewSurface {
MediaRow(
row = HomeBrowseRow(
id = "latest-movies",
title = "Recent releases",
items = listOf(
BaseItem(id = "first", name = "First film", type = "Movie"),
BaseItem(id = "second", name = "Second film", type = "Movie"),
),
kind = MediaRowKind.MOVIES,
emptyMessage = "No films",
),
availableWidth = 960.dp,
contentEntryFocusRequester = null,
returnFocusItemId = null,
returnFocusRequester = remember { FocusRequester() },
verticalFocusRequest = null,
onVerticalFocusRequestConsumed = {},
onMoveVertical = { _, _ -> false },
onContentFocused = {},
onItemFocused = { _, _ -> },
onItemSelected = {},
onItemLongPressed = {},
viewAllLabel = "View All",
onViewAll = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
compose.waitForIdle()
val secondCard = compose.onNodeWithContentDescription("Second film", substring = true)
secondCard.requestFocus()
compose.waitForIdle()
secondCard.assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithContentDescription("View All").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
secondCard.assertIsFocused()
}
}
@@ -66,5 +66,7 @@ class MetadataHeroOrderTest {
) )
assertEquals(60L, metadataHeroMinutesRemaining(item)) assertEquals(60L, metadataHeroMinutesRemaining(item))
assertEquals("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
assertEquals("1 minute remaining", metadataHeroTimeRemainingLabel(1L))
} }
} }
@@ -18,12 +18,12 @@ class ResumableMediaCardTest {
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null) ).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
assertEquals("The Show", model.title) assertEquals("The Show", model.title)
assertEquals("S01E01 - Pilot", model.episodeLabel) assertEquals("S01E01 · Pilot", model.episodeLabel)
} }
@Test @Test
fun episodeIdentityIncludesName() { fun episodeIdentityIncludesName() {
assertEquals("S01E01 - Pilot", episodeLabel(1, 1, "Pilot")) assertEquals("S01E01 · Pilot", episodeLabel(1, 1, "Pilot"))
} }
@Test @Test
@@ -10,7 +10,6 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
@@ -32,7 +31,7 @@ class SettingsRailFocusTest {
val compose = createComposeRule() val compose = createComposeRule()
@Test @Test
fun `up from about reaches storage`() { fun `up from storage reaches devices`() {
compose.setContent { Fixture() } compose.setContent { Fixture() }
compose.waitForIdle() compose.waitForIdle()
@@ -43,34 +42,11 @@ class SettingsRailFocusTest {
compose.waitForIdle() compose.waitForIdle()
compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").assertIsFocused() compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").assertIsFocused()
} }
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
// The rail settles onto the page a beat after the focus lands on it; the press
// worth testing is the one made after the About page is actually drawn.
compose.waitUntil {
compose.onAllNodesWithTag("settings-page-about").fetchSemanticsNodes().isNotEmpty()
}
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-storage").assertIsFocused() compose.onNodeWithTag("settings-rail-storage").assertIsFocused()
}
@Test
fun `up from the top of the about pane leaves the pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
val top = MembyReleaseHistory.first().version
compose.onNodeWithTag("settings-release-$top").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle() compose.waitForIdle()
compose.onNodeWithTag("settings-release-$top").assertIsNotFocused() compose.onNodeWithTag("settings-rail-devices").assertIsFocused()
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
} }
@Test @Test
@@ -89,26 +65,6 @@ class SettingsRailFocusTest {
compose.onNodeWithTag("settings-rail-playback").assertIsFocused() compose.onNodeWithTag("settings-rail-playback").assertIsFocused()
} }
/** The pane's own vertical navigation is untouched by the escape above it. */
@Test
fun `down and up move between rows inside a pane`() {
compose.setContent { Fixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").requestFocus()
compose.waitForIdle()
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.waitForIdle()
val releases = MembyReleaseHistory
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-${releases[1].version}").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithTag("settings-release-${releases[0].version}").assertIsFocused()
}
@Composable @Composable
private fun Fixture() { private fun Fixture() {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) } var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
@@ -118,7 +74,6 @@ class SettingsRailFocusTest {
state = SettingsPanelState( state = SettingsPanelState(
selectedPage = selectedPage, selectedPage = selectedPage,
installedVersion = "0.1.60", installedVersion = "0.1.60",
releaseHistory = MembyReleaseHistory,
), ),
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }), actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
overlay = false, overlay = false,
@@ -19,7 +19,6 @@ import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.ui.PreviewSurface import com.ponzischeme89.memby.ui.PreviewSurface
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -155,30 +154,6 @@ class SettingsSheetScreenshotTest {
assertEquals(togglesByPage.values.flatten().toSet(), turnedOn) assertEquals(togglesByPage.values.flatten().toSet(), turnedOn)
} }
@Test
fun `about lists the release history and opens one release`() {
compose.setContent { InteractiveSettingsFixture() }
compose.waitForIdle()
compose.onNodeWithTag("settings-rail-about").performClick()
compose.waitForIdle()
val releases = MembyReleaseHistory
assertTrue("the shipped changelog parsed to nothing", releases.size >= 2)
compose.onNodeWithTag("settings-release-${releases[1].version}")
.performScrollTo()
.performClick()
compose.waitForIdle()
compose.onNodeWithText(releases[1].changes.first()).assertExists()
}
@Test
fun `about version history`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.ABOUT)
}
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
}
/** /**
* The panel wired to real state, so a press changes something a test can read. Every * The panel wired to real state, so a press changes something a test can read. Every
* switch starts off: the assertion worth making is that each one can be turned *on*, * switch starts off: the assertion worth making is that each one can be turned *on*,
+3
View File
@@ -366,6 +366,9 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
if !ok || len(metadataOrder) == 0 { if !ok || len(metadataOrder) == 0 {
t.Fatalf("status response carried no global metadata hero order: %v", body) t.Fatalf("status response carried no global metadata hero order: %v", body)
} }
if body["metadataHeroTimeRemainingColour"] != store.MetadataHeroTimeRemainingColourGreen {
t.Fatalf("status response carried no global time-remaining colour: %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.
+3 -3
View File
@@ -42,7 +42,7 @@ type embyHealthState struct {
retryEvery time.Duration retryEvery time.Duration
consecutive int consecutive int
// version is what Emby last said it was. Kept across a failed probe on purpose: the // version is what Emby last said it was. Kept across a failed probe on purpose: the
// About page reads it, and blanking it during an outage would replace a fact that is // The Settings rail reads it, and blanking it during an outage would replace a fact that is
// still true with nothing. // still true with nothing.
version string version string
} }
@@ -65,7 +65,7 @@ func (h *embyHealth) begin(interval time.Duration, now time.Time) {
// retune follows the operator changing the probe's cadence, or switching it off, without // retune follows the operator changing the probe's cadence, or switching it off, without
// disturbing what the probe has already found. It is deliberately not `begin`: that // disturbing what the probe has already found. It is deliberately not `begin`: that
// starts a fresh watch and drops the Emby version with it, which the About page reads and // starts a fresh watch and drops the Emby version with it, which the Settings rail reads and
// which is still true whatever the interval is now. // which is still true whatever the interval is now.
func (h *embyHealth) retune(interval time.Duration, now time.Time) { func (h *embyHealth) retune(interval time.Duration, now time.Time) {
h.mu.Lock() h.mu.Lock()
@@ -119,7 +119,7 @@ type embyHealthPayload struct {
Since string `json:"since,omitempty"` Since string `json:"since,omitempty"`
CheckedAt string `json:"checkedAt,omitempty"` CheckedAt string `json:"checkedAt,omitempty"`
RetrySeconds int `json:"retrySeconds"` RetrySeconds int `json:"retrySeconds"`
// Version is Emby's own, for the television's About page. Omitted rather than sent // Version is Emby's own, for the television's Settings rail. Omitted rather than sent
// empty, so a client can tell "not known yet" from "known to be blank" — the first // empty, so a client can tell "not known yet" from "known to be blank" — the first
// probe may not have completed, and with the probe switched off none ever will. // probe may not have completed, and with the probe switched off none ever will.
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
+1 -1
View File
@@ -94,7 +94,7 @@ func TestEmbyHealthPayloadCarriesTheRetryInterval(t *testing.T) {
} }
} }
// The About page prints Emby's version beside the gateway's, so it has to survive the // The Settings rail prints Emby's version beside the gateway's, so it has to survive the
// outage that is exactly when somebody goes looking at that page. A probe that fails // outage that is exactly when somebody goes looking at that page. A probe that fails
// carries no version, and blanking the last known one would replace a fact that is still // carries no version, and blanking the last known one would replace a fact that is still
// true with nothing. // true with nothing.
+6 -4
View File
@@ -130,6 +130,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
} }
compatible, compatibilityMessage := compatibilityFor(r) compatible, compatibilityMessage := compatibilityFor(r)
featurePolicy := s.currentFeaturePolicy(r.Context()) featurePolicy := s.currentFeaturePolicy(r.Context())
metadataHero := s.metadataHeroSettings.get()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled, "maintenance": state.Enabled,
"quietTime": quiet.Active, "quietTime": quiet.Active,
@@ -153,10 +154,11 @@ 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 // The small metadata hero presentation document rides the existing poll so a saved
// ride the existing poll so a saved layout reaches an open launcher immediately, // layout or colour reaches an open launcher immediately, without manufacturing a
// without manufacturing a per-user preference revision for a global change. // per-user preference revision for a global change.
"metadataHeroContentOrder": s.metadataHeroSettings.get().ContentOrder, "metadataHeroContentOrder": metadataHero.ContentOrder,
"metadataHeroTimeRemainingColour": metadataHero.TimeRemainingColour,
// 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
@@ -86,12 +86,13 @@ func (s *Server) handleAdminMetadataHeroSettings(w http.ResponseWriter, r *http.
return return
} }
s.metadataHeroSettings.set(stored) s.metadataHeroSettings.set(stored)
if !slices.Equal(previous.ContentOrder, stored.ContentOrder) { if !slices.Equal(previous.ContentOrder, stored.ContentOrder) ||
previous.TimeRemainingColour != stored.TimeRemainingColour {
s.publishAdmin(r.Context(), adminevents.Event{ s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeSettingsChanged, Type: adminevents.TypeSettingsChanged,
Severity: adminevents.SeverityInfo, Severity: adminevents.SeverityInfo,
Title: "Metadata hero changed", Title: "Metadata hero changed",
Summary: "The household-wide metadata hero content order was changed", Summary: "The household-wide metadata hero presentation was changed",
Actor: operator, Actor: operator,
Link: "/admin/metadata-hero", Link: "/admin/metadata-hero",
}) })
@@ -9,17 +9,27 @@ import (
func TestMetadataHeroSettingsStateHasAUsableGlobalDefault(t *testing.T) { func TestMetadataHeroSettingsStateHasAUsableGlobalDefault(t *testing.T) {
var state metadataHeroSettingsState var state metadataHeroSettingsState
got := state.get().ContentOrder settings := state.get()
got := settings.ContentOrder
if !reflect.DeepEqual(got, store.DefaultMetadataHeroContentOrder) { if !reflect.DeepEqual(got, store.DefaultMetadataHeroContentOrder) {
t.Fatalf("content order = %v, want %v", got, store.DefaultMetadataHeroContentOrder) t.Fatalf("content order = %v, want %v", got, store.DefaultMetadataHeroContentOrder)
} }
if settings.TimeRemainingColour != store.MetadataHeroTimeRemainingColourGreen {
t.Fatalf("time remaining colour = %q, want green", settings.TimeRemainingColour)
}
} }
func TestMetadataHeroSettingsStateNormalisesEveryWrite(t *testing.T) { func TestMetadataHeroSettingsStateNormalisesEveryWrite(t *testing.T) {
var state metadataHeroSettingsState var state metadataHeroSettingsState
state.set(store.MetadataHeroSettings{ContentOrder: []string{"summary", "unknown", "summary", "title"}}) state.set(store.MetadataHeroSettings{
ContentOrder: []string{"summary", "unknown", "summary", "title"},
TimeRemainingColour: " WHITE ",
})
want := []string{"summary", "title"} want := []string{"summary", "title"}
if got := state.get().ContentOrder; !reflect.DeepEqual(got, want) { if got := state.get().ContentOrder; !reflect.DeepEqual(got, want) {
t.Fatalf("content order = %v, want %v", got, want) t.Fatalf("content order = %v, want %v", got, want)
} }
if got := state.get().TimeRemainingColour; got != store.MetadataHeroTimeRemainingColourWhite {
t.Fatalf("time remaining colour = %q, want white", got)
}
} }
+17 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -15,12 +16,14 @@ import (
const MetadataHeroSettingsKey = "metadata_hero_settings" const MetadataHeroSettingsKey = "metadata_hero_settings"
const ( const (
MetadataHeroTitle = "title" MetadataHeroTitle = "title"
MetadataHeroRatings = "ratings" MetadataHeroRatings = "ratings"
MetadataHeroFacts = "facts" MetadataHeroFacts = "facts"
MetadataHeroSummary = "summary" MetadataHeroSummary = "summary"
MetadataHeroReason = "recommendation_reason" MetadataHeroReason = "recommendation_reason"
MetadataHeroTime = "time_remaining" MetadataHeroTime = "time_remaining"
MetadataHeroTimeRemainingColourGreen = "green"
MetadataHeroTimeRemainingColourWhite = "white"
) )
var DefaultMetadataHeroContentOrder = []string{ var DefaultMetadataHeroContentOrder = []string{
@@ -43,9 +46,10 @@ var MetadataHeroContentOptions = []string{
// outside user preferences because the hero is part of the product's shared composition, // outside user preferences because the hero is part of the product's shared composition,
// not something that should change when the viewer changes. // not something that should change when the viewer changes.
type MetadataHeroSettings struct { type MetadataHeroSettings struct {
ContentOrder []string `json:"contentOrder"` ContentOrder []string `json:"contentOrder"`
UpdatedAt time.Time `json:"updatedAt"` TimeRemainingColour string `json:"timeRemainingColour"`
UpdatedBy string `json:"updatedBy,omitempty"` UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
} }
func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSettings { func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSettings {
@@ -62,6 +66,10 @@ func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSe
order = append([]string(nil), DefaultMetadataHeroContentOrder...) order = append([]string(nil), DefaultMetadataHeroContentOrder...)
} }
settings.ContentOrder = order settings.ContentOrder = order
settings.TimeRemainingColour = strings.ToLower(strings.TrimSpace(settings.TimeRemainingColour))
if settings.TimeRemainingColour != MetadataHeroTimeRemainingColourWhite {
settings.TimeRemainingColour = MetadataHeroTimeRemainingColourGreen
}
return settings return settings
} }
@@ -35,3 +35,15 @@ func TestNormalizeMetadataHeroSettingsAcceptsOptionalContent(t *testing.T) {
t.Fatalf("content order = %v, want %v", got, want) t.Fatalf("content order = %v, want %v", got, want)
} }
} }
func TestNormalizeMetadataHeroSettingsNormalisesTimeRemainingColour(t *testing.T) {
if got := NormalizeMetadataHeroSettings(MetadataHeroSettings{}).TimeRemainingColour; got != MetadataHeroTimeRemainingColourGreen {
t.Fatalf("default time remaining colour = %q, want green", got)
}
if got := NormalizeMetadataHeroSettings(MetadataHeroSettings{TimeRemainingColour: " WHITE "}).TimeRemainingColour; got != MetadataHeroTimeRemainingColourWhite {
t.Fatalf("time remaining colour = %q, want white", got)
}
if got := NormalizeMetadataHeroSettings(MetadataHeroSettings{TimeRemainingColour: "orange"}).TimeRemainingColour; got != MetadataHeroTimeRemainingColourGreen {
t.Fatalf("unknown time remaining colour = %q, want green", got)
}
}