0.3.01
This commit is contained in:
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
+1
-1
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-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="stylesheet" crossorigin href="/admin/assets/index-BS_y3llT.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -789,6 +789,7 @@ export interface MetadataHeroOption {
|
||||
|
||||
export interface MetadataHeroSettings {
|
||||
contentOrder: string[];
|
||||
timeRemainingColour: 'green' | 'white';
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Banner, Button, Card, Loading, PageHead, Segments, Toggle } from '../components/ui';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
|
||||
@@ -12,12 +12,17 @@ export function MetadataHeroPage() {
|
||||
const { busy, run } = useAction();
|
||||
const { wrap } = useToast();
|
||||
const [order, setOrder] = useState<string[] | null>(null);
|
||||
const [timeRemainingColour, setTimeRemainingColour] = useState<'green' | 'white' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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 selectedTimeRemainingColour = timeRemainingColour ?? 'green';
|
||||
const options = query.data?.options ?? [];
|
||||
const displayOptions = [
|
||||
...selected,
|
||||
@@ -35,12 +40,16 @@ export function MetadataHeroPage() {
|
||||
};
|
||||
const save = () => run('save', async () => {
|
||||
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.',
|
||||
);
|
||||
if (response) {
|
||||
query.set(response);
|
||||
setOrder(response.settings.contentOrder);
|
||||
setTimeRemainingColour(response.settings.timeRemainingColour);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,7 +71,10 @@ export function MetadataHeroPage() {
|
||||
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
|
||||
Save metadata hero
|
||||
</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 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
|
||||
@@ -111,8 +135,8 @@ export function MetadataHeroPage() {
|
||||
<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>
|
||||
if (section === 'time_remaining') return <div className="metadata-preview-progress" data-colour={selectedTimeRemainingColour} 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;
|
||||
|
||||
+12
-1
@@ -3698,6 +3698,16 @@ details summary {
|
||||
}
|
||||
.metadata-hero-order-actions { display: flex; gap: 5px; }
|
||||
.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 {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
@@ -3805,7 +3815,8 @@ details summary {
|
||||
height: 100%;
|
||||
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; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
|
||||
+16
-1
@@ -38,6 +38,15 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
|
||||
// the complete legal documents offline. Deployments can override the public source URL
|
||||
// without changing application code.
|
||||
val membySourceUrl: String =
|
||||
(project.findProperty("memby.sourceUrl") as String?)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: "https://g.sublogue.com/admin/memby"
|
||||
|
||||
fun buildConfigString(value: String): String =
|
||||
"\"" + value
|
||||
.replace("\\", "\\\\")
|
||||
@@ -49,8 +58,11 @@ fun buildConfigString(value: String): String =
|
||||
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
|
||||
val changelogText = rootProject.file("CHANGELOG.md").readText()
|
||||
val gplLicenseText = rootProject.file("LICENSE").readText()
|
||||
val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
val defaultVersionName = "0.3.00"
|
||||
val defaultVersionName = "0.3.01"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -114,6 +126,9 @@ extensions.configure<ApplicationExtension> {
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
||||
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
|
||||
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
|
||||
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
private val _heroRevision = MutableStateFlow("")
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
@@ -134,6 +135,8 @@ class MaintenanceMonitor(
|
||||
|
||||
/** One household-wide composition, delivered by the status poll to every viewer. */
|
||||
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
|
||||
@@ -188,18 +191,18 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
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()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* probe is switched off, or the gateway predates the field — and About then prints 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 the rail then prints the
|
||||
* gateway's version alone rather than an empty pair of brackets.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
val embyVersion: StateFlow<String> = _embyVersion.asStateFlow()
|
||||
@@ -283,8 +286,9 @@ class MaintenanceMonitor(
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -323,6 +327,12 @@ class MaintenanceMonitor(
|
||||
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
||||
.distinct()
|
||||
.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
|
||||
_heroRevision.value = status.hero.revision
|
||||
_installPermissionPrompt.value =
|
||||
@@ -339,7 +349,7 @@ class MaintenanceMonitor(
|
||||
// should not have that fact disappear from the poll.
|
||||
_embyOutage.value = outageFrom(status.emby)
|
||||
// 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.
|
||||
status.emby.version.takeIf(String::isNotBlank)?.let {
|
||||
_embyVersion.value = it
|
||||
@@ -367,8 +377,9 @@ class MaintenanceMonitor(
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -475,6 +486,8 @@ class MaintenanceMonitor(
|
||||
listOf("title", "ratings", "facts", "summary")
|
||||
internal val METADATA_HERO_CONTENT_OPTIONS =
|
||||
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 RATE_LIMIT_FALLBACK_MS = 60_000L
|
||||
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
|
||||
|
||||
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
|
||||
val preferencesRevision: Long = 0,
|
||||
/** Household-wide order of the focused metadata hero's content blocks. */
|
||||
val metadataHeroContentOrder: List<String> = emptyList(),
|
||||
/** Household-wide colour for the optional time-remaining caption: green or white. */
|
||||
val metadataHeroTimeRemainingColour: String = "green",
|
||||
/**
|
||||
* 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
|
||||
@@ -317,9 +319,9 @@ data class GatewayEmbyHealth(
|
||||
val checkedAt: String = "",
|
||||
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
|
||||
* 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 = "",
|
||||
) {
|
||||
|
||||
@@ -284,6 +284,7 @@ internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
|
||||
internal fun FocusedHomeMetadata(
|
||||
homeViewModel: HomeViewModel,
|
||||
metadataHeroContentOrder: List<String>,
|
||||
metadataHeroTimeRemainingColour: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
||||
@@ -295,6 +296,7 @@ internal fun FocusedHomeMetadata(
|
||||
item = focusedItem,
|
||||
loading = homeContent.loading.isNotEmpty(),
|
||||
contentOrder = metadataHeroContentOrder,
|
||||
timeRemainingColour = metadataHeroTimeRemainingColour,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,8 @@ internal fun HomeScreen(
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val metadataHeroContentOrder by
|
||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||
val metadataHeroTimeRemainingColour by
|
||||
ServiceLocator.maintenance.metadataHeroTimeRemainingColour.collectAsStateWithLifecycle()
|
||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
||||
@@ -286,9 +288,6 @@ internal fun HomeScreen(
|
||||
var quickMenuTrailerAvailable by remember { mutableStateOf(false) }
|
||||
var quickMenuRowId 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) {
|
||||
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
|
||||
}
|
||||
@@ -847,7 +846,6 @@ internal fun HomeScreen(
|
||||
)
|
||||
LaunchedEffect(selectedDestination) {
|
||||
focusedHomeRowId = null
|
||||
rowBrowseTarget = null
|
||||
}
|
||||
LaunchedEffect(selectedDestination, settings.forYouMinutes) {
|
||||
if (
|
||||
@@ -868,7 +866,6 @@ internal fun HomeScreen(
|
||||
showNotifications -> "notifications"
|
||||
userSwitcherVisible || showProfiles -> "profiles"
|
||||
detailsItem != null -> "details"
|
||||
rowBrowseTarget != null -> "row_browse"
|
||||
selectedMyShow != null -> "my_show_details"
|
||||
else -> selectedDestination.name.lowercase()
|
||||
}
|
||||
@@ -941,7 +938,6 @@ internal fun HomeScreen(
|
||||
navigationExpanded = it
|
||||
},
|
||||
onDestinationSelected = { destination ->
|
||||
rowBrowseTarget = null
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "select",
|
||||
screen = journeyScreen, feature = destination.name.lowercase(),
|
||||
@@ -1057,53 +1053,6 @@ internal fun HomeScreen(
|
||||
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) {
|
||||
// Remembered, or this list is a fresh instance on every recomposition
|
||||
// and the screen re-derives its genre chips each time.
|
||||
@@ -1432,6 +1381,7 @@ internal fun HomeScreen(
|
||||
FocusedHomeMetadata(
|
||||
homeViewModel = homeViewModel,
|
||||
metadataHeroContentOrder = metadataHeroContentOrder,
|
||||
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
|
||||
modifier = Modifier
|
||||
.height(metadataHeight)
|
||||
// LazyColumn is drawn later as a sibling. Keep the hero
|
||||
@@ -1565,7 +1515,6 @@ internal fun HomeScreen(
|
||||
row.items.take(8).map { it.id },
|
||||
)
|
||||
}
|
||||
val browseTarget = homeRowBrowseTarget(row)
|
||||
MediaRow(
|
||||
modifier = Modifier,
|
||||
row = row,
|
||||
@@ -1727,24 +1676,6 @@ internal fun HomeScreen(
|
||||
horizontalState = horizontalStates.getOrPut(
|
||||
"${selectedDestination.name}:${row.id}",
|
||||
) { 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?,
|
||||
loading: Boolean,
|
||||
contentOrder: List<String> = emptyList(),
|
||||
timeRemainingColour: String = "green",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
@@ -71,6 +72,7 @@ internal fun MetadataHero(
|
||||
item = item,
|
||||
loading = loading,
|
||||
contentOrder = contentOrder,
|
||||
timeRemainingColour = timeRemainingColour,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
|
||||
@@ -104,7 +104,7 @@ internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
|
||||
null
|
||||
}
|
||||
return when {
|
||||
code != null && cleanName.isNotEmpty() -> "$code - $cleanName"
|
||||
code != null && cleanName.isNotEmpty() -> "$code · $cleanName"
|
||||
code != null -> code
|
||||
cleanName.isNotEmpty() -> cleanName
|
||||
else -> null
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.weight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -51,7 +50,6 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -134,6 +132,7 @@ fun MediaMetadataPanel(
|
||||
item: BaseItem?,
|
||||
loading: Boolean,
|
||||
contentOrder: List<String>,
|
||||
timeRemainingColour: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
@@ -157,7 +156,13 @@ fun MediaMetadataPanel(
|
||||
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,
|
||||
compact: Boolean,
|
||||
contentOrder: List<String>,
|
||||
timeRemainingColour: String,
|
||||
) {
|
||||
if (item.isSchedule) {
|
||||
ScheduleMetadataContent(item, contentWidth, compact)
|
||||
@@ -271,7 +277,7 @@ private fun MetadataContent(
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item)
|
||||
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item, timeRemainingColour)
|
||||
MetadataHeroSection.Summary -> Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (item.isEpisode && item.seriesName != null) Text(
|
||||
item.episodeCode
|
||||
?.filter { it.isLetterOrDigit() }
|
||||
?.lowercase(Locale.ROOT)
|
||||
.orEmpty(),
|
||||
color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item.isEpisode) {
|
||||
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
|
||||
Text(
|
||||
label,
|
||||
color = MutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -434,7 +444,7 @@ private fun MetadataStatus(item: BaseItem) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataTimeRemaining(item: BaseItem) {
|
||||
private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
val minutes = metadataHeroMinutesRemaining(item) ?: return
|
||||
@@ -453,15 +463,19 @@ private fun MetadataTimeRemaining(item: BaseItem) {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (minutes == 1) "1 MINUTE REMAINING" else "$minutes MINUTES REMAINING",
|
||||
color = MutedText,
|
||||
metadataHeroTimeRemainingLabel(minutes),
|
||||
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun metadataHeroTimeRemainingLabel(minutes: Long): String =
|
||||
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
|
||||
|
||||
internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? {
|
||||
val position = item.userData?.playbackPositionTicks ?: 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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.Text
|
||||
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.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
|
||||
@@ -175,20 +166,10 @@ internal fun MediaRow(
|
||||
density: String = "standard",
|
||||
artworkStyle: String = "automatic",
|
||||
horizontalState: LazyListState? = null,
|
||||
viewAllLabel: String? = null,
|
||||
onViewAll: (() -> Unit)? = null,
|
||||
) {
|
||||
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||
val rowState = horizontalState ?: savedRowState
|
||||
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
|
||||
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||
?.itemIndex
|
||||
@@ -216,39 +197,6 @@ internal fun MediaRow(
|
||||
}
|
||||
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)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -266,42 +214,6 @@ internal fun MediaRow(
|
||||
fontSize = 20.sp,
|
||||
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 {
|
||||
row.items.isEmpty() && row.loading -> {
|
||||
@@ -369,52 +281,22 @@ internal fun MediaRow(
|
||||
if (item.id == returnFocusItemId) {
|
||||
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
if (item.id == headerReturnTargetId) {
|
||||
cardModifier = cardModifier.focusRequester(headerReturnFocusRequester)
|
||||
}
|
||||
if (index == requestedEntryIndex) {
|
||||
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
|
||||
}
|
||||
if (index == pagedCardIndex) {
|
||||
cardModifier = cardModifier.focusRequester(pagedCardFocusRequester)
|
||||
}
|
||||
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
requestHeaderFocus() ||
|
||||
onMoveVertical(index, RowFocusDirection.UP)
|
||||
onMoveVertical(index, RowFocusDirection.UP)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
val focused: () -> Unit = {
|
||||
headerReturnItemId = item.id
|
||||
onContentFocused()
|
||||
// LazyRow already knows the semantic position. Passing it on
|
||||
// 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 =
|
||||
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.
|
||||
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
|
||||
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
|
||||
ABOUT("About", "Version/changelog", MembyIcon.Info),
|
||||
}
|
||||
|
||||
// Black, and one lit thing at a time.
|
||||
@@ -266,7 +265,6 @@ internal data class SettingsPanelState(
|
||||
val installedVersion: String = "",
|
||||
val gatewayVersion: String = "",
|
||||
val embyVersion: String = "",
|
||||
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
|
||||
val devices: List<GatewayDevice> = emptyList(),
|
||||
val devicesLoading: Boolean = false,
|
||||
val devicesError: String? = null,
|
||||
@@ -337,8 +335,8 @@ fun SettingsSheet(
|
||||
withContext(NonCancellable) { block() }
|
||||
}
|
||||
}
|
||||
// Kept only for the version this TV is running, which About prints. Nothing here checks
|
||||
// for an update: see the note on SettingsPage.
|
||||
// Kept only for the version this TV is running, which the Settings rail prints. Nothing
|
||||
// here checks for an update: see the note on SettingsPage.
|
||||
val checker = remember { UpdateChecker(context) }
|
||||
// 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
|
||||
@@ -804,6 +802,9 @@ internal fun SettingsPanelContent(
|
||||
SettingsSecondaryRail(
|
||||
selected = state.selectedPage,
|
||||
onSelected = actions.onPageSelected,
|
||||
installedVersion = state.installedVersion,
|
||||
gatewayVersion = state.gatewayVersion,
|
||||
embyVersion = state.embyVersion,
|
||||
firstFocusRequester = firstFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
@@ -1104,43 +1105,6 @@ internal fun SettingsPanelContent(
|
||||
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))
|
||||
}
|
||||
@@ -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)`.
|
||||
*
|
||||
* 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(
|
||||
selected: SettingsPage,
|
||||
onSelected: (SettingsPage) -> Unit,
|
||||
installedVersion: String,
|
||||
gatewayVersion: String,
|
||||
embyVersion: String,
|
||||
firstFocusRequester: FocusRequester?,
|
||||
navigationFocusRequester: FocusRequester?,
|
||||
contentFocusRequester: FocusRequester,
|
||||
@@ -1532,6 +1499,24 @@ private fun SettingsSecondaryRail(
|
||||
}
|
||||
}
|
||||
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(
|
||||
"↑ ↓ moves between pages",
|
||||
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
|
||||
// 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
|
||||
// on all seven pages to answer a question asked on two of them, where Updates prints
|
||||
// it as "On this TV" and About prints it beside the app's own name.
|
||||
// Version details live beneath the navigation in the rail, where they remain visible
|
||||
// without taking a page of their own.
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
@@ -1590,7 +1574,7 @@ private fun VersionRow(label: String, version: String) {
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
@@ -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
|
||||
private fun SettingDivider() {
|
||||
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.Backspace
|
||||
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.Add
|
||||
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.PlayArrow
|
||||
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.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
@@ -111,7 +111,7 @@ object MaterialIconPack {
|
||||
MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder },
|
||||
MembyIcon.Bookmark to { Icons.Default.Bookmark },
|
||||
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.HideWatched to { Icons.Default.VisibilityOff },
|
||||
MembyIcon.Check to { Icons.Default.Check },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.update
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
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
|
||||
* spent.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun clear() {
|
||||
_required.resetReplayCache()
|
||||
}
|
||||
|
||||
@@ -146,13 +146,14 @@ class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes explicit client server protocol mismatch`() {
|
||||
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("App protocol 2, server protocol 1.", status.compatibilityMessage)
|
||||
assertEquals("0.1.29", status.gatewayVersion)
|
||||
assertEquals(1, status.serverProtocol)
|
||||
assertEquals("white", status.metadataHeroTimeRemainingColour)
|
||||
}
|
||||
|
||||
@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("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
|
||||
assertEquals("1 minute remaining", metadataHeroTimeRemainingLabel(1L))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ class ResumableMediaCardTest {
|
||||
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertEquals("The Show", model.title)
|
||||
assertEquals("S01E01 - Pilot", model.episodeLabel)
|
||||
assertEquals("S01E01 · Pilot", model.episodeLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeIdentityIncludesName() {
|
||||
assertEquals("S01E01 - Pilot", episodeLabel(1, 1, "Pilot"))
|
||||
assertEquals("S01E01 · Pilot", episodeLabel(1, 1, "Pilot"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotFocused
|
||||
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.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
@@ -32,7 +31,7 @@ class SettingsRailFocusTest {
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `up from about reaches storage`() {
|
||||
fun `up from storage reaches devices`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
@@ -43,34 +42,11 @@ class SettingsRailFocusTest {
|
||||
compose.waitForIdle()
|
||||
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()
|
||||
}
|
||||
|
||||
@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.waitForIdle()
|
||||
compose.onNodeWithTag("settings-release-$top").assertIsNotFocused()
|
||||
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
|
||||
compose.onNodeWithTag("settings-rail-devices").assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,26 +65,6 @@ class SettingsRailFocusTest {
|
||||
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
|
||||
private fun Fixture() {
|
||||
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
|
||||
@@ -118,7 +74,6 @@ class SettingsRailFocusTest {
|
||||
state = SettingsPanelState(
|
||||
selectedPage = selectedPage,
|
||||
installedVersion = "0.1.60",
|
||||
releaseHistory = MembyReleaseHistory,
|
||||
),
|
||||
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
|
||||
overlay = false,
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.ponzischeme89.memby.data.ImageCacheSize
|
||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -155,30 +154,6 @@ class SettingsSheetScreenshotTest {
|
||||
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
|
||||
* switch starts off: the assertion worth making is that each one can be turned *on*,
|
||||
|
||||
@@ -366,6 +366,9 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
||||
if !ok || len(metadataOrder) == 0 {
|
||||
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
|
||||
// operator's hero change: a television comparing against a missing field would go on
|
||||
// drawing yesterday's hero until it was next restarted.
|
||||
|
||||
@@ -42,7 +42,7 @@ type embyHealthState struct {
|
||||
retryEvery time.Duration
|
||||
consecutive int
|
||||
// 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.
|
||||
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
|
||||
// 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.
|
||||
func (h *embyHealth) retune(interval time.Duration, now time.Time) {
|
||||
h.mu.Lock()
|
||||
@@ -119,7 +119,7 @@ type embyHealthPayload struct {
|
||||
Since string `json:"since,omitempty"`
|
||||
CheckedAt string `json:"checkedAt,omitempty"`
|
||||
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
|
||||
// probe may not have completed, and with the probe switched off none ever will.
|
||||
Version string `json:"version,omitempty"`
|
||||
|
||||
@@ -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
|
||||
// carries no version, and blanking the last known one would replace a fact that is still
|
||||
// true with nothing.
|
||||
|
||||
@@ -130,6 +130,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
}
|
||||
compatible, compatibilityMessage := compatibilityFor(r)
|
||||
featurePolicy := s.currentFeaturePolicy(r.Context())
|
||||
metadataHero := s.metadataHeroSettings.get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"maintenance": state.Enabled,
|
||||
"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
|
||||
// delivery channel for an operator pushing someone's settings.
|
||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
||||
// Four small ids are the complete household-wide metadata hero composition. They
|
||||
// ride the existing poll so a saved layout reaches an open launcher immediately,
|
||||
// without manufacturing a per-user preference revision for a global change.
|
||||
"metadataHeroContentOrder": s.metadataHeroSettings.get().ContentOrder,
|
||||
// The small metadata hero presentation document rides the existing poll so a saved
|
||||
// layout or colour reaches an open launcher immediately, without manufacturing a
|
||||
// per-user preference revision for a global change.
|
||||
"metadataHeroContentOrder": metadataHero.ContentOrder,
|
||||
"metadataHeroTimeRemainingColour": metadataHero.TimeRemainingColour,
|
||||
// The theme, as an id and a revision rather than the palette itself — the
|
||||
// preferencesRevision precedent, for the same reason. The set refetches /v1/theme
|
||||
// only when one of these moves, which is what makes a season arriving at midnight
|
||||
|
||||
@@ -86,12 +86,13 @@ func (s *Server) handleAdminMetadataHeroSettings(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
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{
|
||||
Type: adminevents.TypeSettingsChanged,
|
||||
Severity: adminevents.SeverityInfo,
|
||||
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,
|
||||
Link: "/admin/metadata-hero",
|
||||
})
|
||||
|
||||
@@ -9,17 +9,27 @@ import (
|
||||
|
||||
func TestMetadataHeroSettingsStateHasAUsableGlobalDefault(t *testing.T) {
|
||||
var state metadataHeroSettingsState
|
||||
got := state.get().ContentOrder
|
||||
settings := state.get()
|
||||
got := settings.ContentOrder
|
||||
if !reflect.DeepEqual(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) {
|
||||
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"}
|
||||
if got := state.get().ContentOrder; !reflect.DeepEqual(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -15,12 +16,14 @@ import (
|
||||
const MetadataHeroSettingsKey = "metadata_hero_settings"
|
||||
|
||||
const (
|
||||
MetadataHeroTitle = "title"
|
||||
MetadataHeroRatings = "ratings"
|
||||
MetadataHeroFacts = "facts"
|
||||
MetadataHeroSummary = "summary"
|
||||
MetadataHeroReason = "recommendation_reason"
|
||||
MetadataHeroTime = "time_remaining"
|
||||
MetadataHeroTitle = "title"
|
||||
MetadataHeroRatings = "ratings"
|
||||
MetadataHeroFacts = "facts"
|
||||
MetadataHeroSummary = "summary"
|
||||
MetadataHeroReason = "recommendation_reason"
|
||||
MetadataHeroTime = "time_remaining"
|
||||
MetadataHeroTimeRemainingColourGreen = "green"
|
||||
MetadataHeroTimeRemainingColourWhite = "white"
|
||||
)
|
||||
|
||||
var DefaultMetadataHeroContentOrder = []string{
|
||||
@@ -43,9 +46,10 @@ var MetadataHeroContentOptions = []string{
|
||||
// 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"`
|
||||
ContentOrder []string `json:"contentOrder"`
|
||||
TimeRemainingColour string `json:"timeRemainingColour"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSettings {
|
||||
@@ -62,6 +66,10 @@ func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSe
|
||||
order = append([]string(nil), DefaultMetadataHeroContentOrder...)
|
||||
}
|
||||
settings.ContentOrder = order
|
||||
settings.TimeRemainingColour = strings.ToLower(strings.TrimSpace(settings.TimeRemainingColour))
|
||||
if settings.TimeRemainingColour != MetadataHeroTimeRemainingColourWhite {
|
||||
settings.TimeRemainingColour = MetadataHeroTimeRemainingColourGreen
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
|
||||
@@ -35,3 +35,15 @@ func TestNormalizeMetadataHeroSettingsAcceptsOptionalContent(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user