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"
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>
+1
View File
@@ -789,6 +789,7 @@ export interface MetadataHeroOption {
export interface MetadataHeroSettings {
contentOrder: string[];
timeRemainingColour: 'green' | 'white';
updatedAt?: string;
updatedBy?: string;
}
+30 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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,6 +286,7 @@ 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 = ""
_installPermissionPrompt.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,6 +377,7 @@ 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 = ""
_installPermissionPrompt.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,14 +342,18 @@ 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
private fun ScheduleMetadataContent(
@@ -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)
}
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*,
+3
View File
@@ -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.
+3 -3
View File
@@ -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"`
+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
// carries no version, and blanking the last known one would replace a fact that is still
// 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)
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)
}
}
+8
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -21,6 +22,8 @@ const (
MetadataHeroSummary = "summary"
MetadataHeroReason = "recommendation_reason"
MetadataHeroTime = "time_remaining"
MetadataHeroTimeRemainingColourGreen = "green"
MetadataHeroTimeRemainingColourWhite = "white"
)
var DefaultMetadataHeroContentOrder = []string{
@@ -44,6 +47,7 @@ var MetadataHeroContentOptions = []string{
// not something that should change when the viewer changes.
type MetadataHeroSettings struct {
ContentOrder []string `json:"contentOrder"`
TimeRemainingColour string `json:"timeRemainingColour"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
@@ -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)
}
}