This commit is contained in:
ponzischeme89
2026-08-11 23:41:10 +12:00
parent 0e183cf560
commit 5fed3360c2
42 changed files with 1340 additions and 130 deletions
+17
View File
@@ -1,3 +1,20 @@
## 0.2.53 — 2026-08-11
- Improved: The play button used by the mini hero cards now appears over every focused poster.
- Improved: Reduced the primary hero logo so its supporting details remain visible.
- Improved: The profile rail now shows the name of the user signed in on the television.
- Improved: Renamed My Alerts to Notifications.
- Improved: Added notifications for every user when Sonarr reports that any show has been cancelled.
- Improved: Added a daily Sonarr lifecycle scan and status history so newly cancelled shows can be identified.
## 0.2.52 — 2026-08-11
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
- Fixed: The show or film logo was missing from the primary home-page hero.
- Fixed: My Alerts did not include an alert when Memby had been updated.
- Fixed: The release date in version history used the incorrect date format.
- Improved: Added soundbar and passthrough settings to the on-screen display.
- Improved: Slimmed down the subtitles menu and made the Get subtitles button easier to see.
- Improved: Added country flags to the subtitles search and menu.
## 0.2.51 — 2026-08-11 ## 0.2.51 — 2026-08-11
- Improved: For You now begins loading after Home arrives, so its personalised rows are usually ready before you open it. - Improved: For You now begins loading after Home arrives, so its personalised rows are usually ready before you open it.
- Fixed: Frequent screen updates can no longer repeat network requests, navigation, analytics, timers or other background work. - Fixed: Frequent screen updates can no longer repeat network requests, navigation, analytics, timers or other background work.
+9 -1
View File
@@ -427,7 +427,7 @@ the alert that announced it has long since fallen out of its window. Things to p
— unauthenticated on purpose, since a probe needing a token would report a stale session — unauthenticated on purpose, since a probe needing a token would report a stale session
as a server outage. as a server outage.
**My Alerts belongs to a person, so it lives in the user picker.** A service alert is the **Notifications belong to a person, so they live in the user picker.** A service alert is the
house being told something; these are one viewer's own news (a followed show returning), house being told something; these are one viewer's own news (a followed show returning),
stored per user on the gateway and following them to whichever television they sign into. stored per user on the gateway and following them to whichever television they sign into.
`ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the `ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the
@@ -450,6 +450,14 @@ behind it. Things to preserve:
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns - **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`. the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`.
- **A cancellation is a transition, not a status read in isolation.**
`sonarr_series_status_history` stores the first daily Sonarr reading as a quiet baseline
and appends only changes after it; `WatchSonarrLifecycle` creates a notification for every
known user when any show moves from continuing/upcoming to ended/deleted. Cancellation
news is household-wide and does not depend on whether a viewer followed the show. Without
the baseline,
enabling the scanner would announce every show that had already ended as new news, and
without durable history a gateway restart could announce the same change again.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed **Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type. 6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.51" val defaultVersionName = "0.2.53"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -403,6 +403,10 @@ data class Settings(
* not seen that build's update notice. * not seen that build's update notice.
*/ */
val whatsNewSeenVersion: String? = null, val whatsNewSeenVersion: String? = null,
/** The most recent app-update alert retained for this television's Notifications page. */
val updateAlertVersion: String? = null,
val updateAlertAt: String? = null,
val updateAlertRead: Boolean = false,
/** /**
* The version the gateway last refused this build over, or null while it has said * The version the gateway last refused this build over, or null while it has said
* nothing. Device state, and deliberately outlives both the session and the process: * nothing. Device state, and deliberately outlives both the session and the process:
@@ -565,6 +569,9 @@ class SettingsStore(private val context: Context) {
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids") val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids") val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version") val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
val UPDATE_ALERT_VERSION = stringPreferencesKey("update_alert_version")
val UPDATE_ALERT_AT = stringPreferencesKey("update_alert_at")
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version") val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision") val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
} }
@@ -1043,11 +1050,32 @@ class SettingsStore(private val context: Context) {
* once. Also written silently on a fresh install, which has not updated from an earlier * once. Also written silently on a fresh install, which has not updated from an earlier
* build and therefore has nothing to announce. * build and therefore has nothing to announce.
*/ */
suspend fun markWhatsNewSeen(version: String) { suspend fun markWhatsNewSeen(version: String, updateAlertAt: String? = null) {
val trimmed = version.trim() val trimmed = version.trim()
if (trimmed.isEmpty()) return if (trimmed.isEmpty()) return
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
preferences[Keys.WHATS_NEW_VERSION] = trimmed preferences[Keys.WHATS_NEW_VERSION] = trimmed
updateAlertAt?.trim()?.takeIf(String::isNotEmpty)?.let { occurredAt ->
preferences[Keys.UPDATE_ALERT_VERSION] = trimmed
preferences[Keys.UPDATE_ALERT_AT] = occurredAt
preferences[Keys.UPDATE_ALERT_READ] = false
}
}
}
suspend fun markUpdateAlertRead() {
context.dataStore.edit { preferences ->
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) {
preferences[Keys.UPDATE_ALERT_READ] = true
}
}
}
suspend fun dismissUpdateAlert() {
context.dataStore.edit { preferences ->
preferences.remove(Keys.UPDATE_ALERT_VERSION)
preferences.remove(Keys.UPDATE_ALERT_AT)
preferences.remove(Keys.UPDATE_ALERT_READ)
} }
} }
@@ -1481,6 +1509,9 @@ class SettingsStore(private val context: Context) {
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(), themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(), onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION], whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
updateAlertVersion = preferences[Keys.UPDATE_ALERT_VERSION],
updateAlertAt = preferences[Keys.UPDATE_ALERT_AT],
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION], requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0, preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
profiles = profiles, profiles = profiles,
@@ -115,6 +115,53 @@ fun normalizeSubtitleLanguage(raw: String?): String {
return subtitleLanguageAliases[base] ?: base return subtitleLanguageAliases[base] ?: base
} }
/** A familiar country marker for a subtitle language, or empty for an unknown code. */
fun subtitleCountryFlag(raw: String?): String {
val tagged = raw?.trim()?.lowercase()?.replace('_', '-').orEmpty()
when {
tagged.startsWith("en-us") -> return "🇺🇸"
tagged.startsWith("en-au") -> return "🇦🇺"
tagged.startsWith("en-nz") -> return "🇳🇿"
tagged.startsWith("pt-br") -> return "🇧🇷"
tagged.startsWith("es-mx") -> return "🇲🇽"
tagged.startsWith("zh-tw") -> return "🇹🇼"
tagged.startsWith("zh-hk") || tagged.startsWith("yue") -> return "🇭🇰"
}
return when (normalizeSubtitleLanguage(raw)) {
"en" -> "🇬🇧"
"it" -> "🇮🇹"
"es" -> "🇪🇸"
"fr" -> "🇫🇷"
"de" -> "🇩🇪"
"pt" -> "🇵🇹"
"nl" -> "🇳🇱"
"sv" -> "🇸🇪"
"da" -> "🇩🇰"
"no" -> "🇳🇴"
"fi" -> "🇫🇮"
"pl" -> "🇵🇱"
"cs" -> "🇨🇿"
"hu" -> "🇭🇺"
"ro" -> "🇷🇴"
"el" -> "🇬🇷"
"ru" -> "🇷🇺"
"uk" -> "🇺🇦"
"tr" -> "🇹🇷"
"ar" -> "🇸🇦"
"he" -> "🇮🇱"
"hi" -> "🇮🇳"
"ja" -> "🇯🇵"
"ko" -> "🇰🇷"
"zh" -> "🇨🇳"
"th" -> "🇹🇭"
"vi" -> "🇻🇳"
else -> ""
}
}
fun subtitleLabelWithFlag(language: String?, label: String): String =
subtitleCountryFlag(language).let { flag -> if (flag.isBlank()) label else "$flag $label" }
/** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */ /** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */
data class SubtitleCandidate( data class SubtitleCandidate(
val id: String, val id: String,
@@ -173,7 +173,7 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
// has one — see [TvNavigationRail]'s calendarEnabled. // has one — see [TvNavigationRail]'s calendarEnabled.
CALENDAR("TV Calendar", Icons.Default.CalendarMonth), CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
FAVORITES("Favourites", Icons.Default.Favorite), FAVORITES("Favourites", Icons.Default.Favorite),
PROFILES("Switch user", Icons.Default.Person), PROFILES("User", Icons.Default.Person),
SETTINGS("Settings", Icons.Default.Settings), SETTINGS("Settings", Icons.Default.Settings),
} }
@@ -361,6 +361,11 @@ fun TvNavigationRail(
destinations.forEach { destination -> destinations.forEach { destination ->
ExpandableNavigationItem( ExpandableNavigationItem(
destination = destination, destination = destination,
label = if (destination == BrowseDestination.PROFILES) {
profileDestinationLabel(activeUsername)
} else {
destination.label
},
selected = destination == selected, selected = destination == selected,
expanded = expanded, expanded = expanded,
modifier = if (destination == focusDestination) { modifier = if (destination == focusDestination) {
@@ -370,7 +375,7 @@ fun TvNavigationRail(
}, },
onFocused = {}, onFocused = {},
onClick = { onDestinationSelected(destination) }, onClick = { onDestinationSelected(destination) },
// My Alerts is one level in, behind the user picker. Without a mark out // Notifications are one level in, behind the user picker. Without a mark out
// here nothing on the launcher would ever say there was news waiting. // here nothing on the launcher would ever say there was news waiting.
badge = if (destination == BrowseDestination.PROFILES) { badge = if (destination == BrowseDestination.PROFILES) {
alertBadgeLabel(alertCount) alertBadgeLabel(alertCount)
@@ -526,12 +531,12 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)), .background(Color.White.copy(alpha = 0.07f)),
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
// My Alerts lives here rather than on the launcher: these alerts belong to a // Notifications live here rather than on the launcher: these belong to a
// person and follow them between televisions, so the menu that already answers // person and follow them between televisions, so the menu that already answers
// "who is watching" is where somebody looks for their own news. The badge is // "who is watching" is where somebody looks for their own news. The badge is
// what replaces the bell that used to sit in the corner of Home. // what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction( UserSwitcherAction(
label = "My Alerts", label = "Notifications",
icon = Icons.Default.Notifications, icon = Icons.Default.Notifications,
badge = alertBadgeLabel(alertCount), badge = alertBadgeLabel(alertCount),
modifier = Modifier modifier = Modifier
@@ -555,7 +560,7 @@ fun UserSwitcherOverlay(
} }
} }
/** My Alerts, then Manage users. See [userSwitcherNextIndex]. */ /** Notifications, then Manage users. See [userSwitcherNextIndex]. */
private const val UserSwitcherActionCount = 2 private const val UserSwitcherActionCount = 2
@Composable @Composable
@@ -678,6 +683,7 @@ private val AlertBadgeRed = Color(0xFFE04747)
@Composable @Composable
fun ExpandableNavigationItem( fun ExpandableNavigationItem(
destination: BrowseDestination, destination: BrowseDestination,
label: String = destination.label,
selected: Boolean, selected: Boolean,
expanded: Boolean, expanded: Boolean,
onFocused: () -> Unit, onFocused: () -> Unit,
@@ -719,8 +725,7 @@ fun ExpandableNavigationItem(
.drawBehind { drawRect(background.value) } .drawBehind { drawRect(background.value) }
.clickable(onClick = onClick) .clickable(onClick = onClick)
.semantics { .semantics {
contentDescription = badge?.let { "${destination.label}, $it waiting" } contentDescription = badge?.let { "$label, $it waiting" } ?: label
?: destination.label
} }
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -773,7 +778,7 @@ fun ExpandableNavigationItem(
} }
if (expanded) { if (expanded) {
Text( Text(
destination.label, label,
color = foreground.value, color = foreground.value,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium,
@@ -799,6 +804,10 @@ internal fun profileInitials(username: String): String {
} }
} }
/** The profile rail names the person currently signed in, not the action behind the row. */
internal fun profileDestinationLabel(username: String): String =
username.trim().ifEmpty { "User" }
@Composable @Composable
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
@@ -2143,6 +2152,9 @@ private fun MediaCard(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp), modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
) )
} }
if (focused) {
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
} }
Text( Text(
item.name, item.name,
@@ -2,7 +2,6 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -16,10 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -30,7 +26,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@@ -43,7 +38,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
@@ -345,6 +339,12 @@ private fun FeaturedMovieCard(
previewArtwork: ImageBitmap?, previewArtwork: ImageBitmap?,
) { ) {
val item = pick.item val item = pick.item
val repository = ServiceLocator.repository
val showLogo = repository.showTitleLogo
val logoUrl = remember(item.id, item.imageTags, item.parentLogoItemId, item.parentLogoImageTag, showLogo) {
if (showLogo) repository.logoUrl(item, 640) else null
}
val useTextTitle = useTextTitleForLogo(logoUrl)
FocusScaleContainer( FocusScaleContainer(
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = onClick,
@@ -403,16 +403,26 @@ private fun FeaturedMovieCard(
// on the least specific thing on it — and it is the line that pushed a // on the least specific thing on it — and it is the line that pushed a
// wrapped title into the button. The three minis beside it keep theirs: // wrapped title into the button. The three minis beside it keep theirs:
// they have no fact line, and there the label is the only reason given. // they have no fact line, and there the label is the only reason given.
Text( if (useTextTitle) {
item.name, Text(
color = Color.White, item.name,
fontSize = 30.sp, color = Color.White,
lineHeight = 32.sp, fontSize = 30.sp,
fontWeight = FontWeight.Bold, lineHeight = 32.sp,
maxLines = 2, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis, maxLines = 2,
onTextLayout = { titleLines = it.lineCount }, overflow = TextOverflow.Ellipsis,
) onTextLayout = { titleLines = it.lineCount },
)
} else {
AsyncImage(
model = logoUrl,
contentDescription = item.name,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.width(240.dp).height(56.dp),
)
}
Spacer(Modifier.height(9.dp)) Spacer(Modifier.height(9.dp))
HeroFactLine(item) HeroFactLine(item)
// The reason takes the synopsis's place rather than adding a line to // The reason takes the synopsis's place rather than adding a line to
@@ -540,24 +550,11 @@ private fun MiniMovieCard(
} }
} }
if (focused) { if (focused) {
Box( MembyArtworkPlayCue(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(end = 16.dp) .padding(end = 16.dp),
.size(38.dp) )
.shadow(14.dp, CircleShape)
.clip(CircleShape)
.background(MembyAccent)
.border(2.dp, Color.White, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
} }
} }
} }
@@ -179,6 +179,26 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.Date import java.util.Date
import java.util.Calendar import java.util.Calendar
import java.time.Instant
internal const val MEMBY_UPDATE_NOTIFICATION_ID = -1L
internal fun membyUpdateNotification(
version: String?,
occurredAt: String?,
read: Boolean,
): UserNotification? {
val installed = version?.trim()?.takeIf(String::isNotEmpty) ?: return null
return UserNotification(
id = MEMBY_UPDATE_NOTIFICATION_ID,
kind = "app-update",
title = "Memby updated",
message = "This TV is now running Memby $installed.",
eventAt = occurredAt,
createdAt = occurredAt.orEmpty(),
readAt = if (read) occurredAt ?: "read" else null,
)
}
/** /**
* The followed show a press stands for, as much of it as the card already knows. * The followed show a press stands for, as much of it as the card already knows.
@@ -498,7 +518,10 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// Persist the receipt before producing the external effect. If Android // Persist the receipt before producing the external effect. If Android
// recreates the activity immediately after the toast, the new composition // recreates the activity immediately after the toast, the new composition
// must not announce the same build a second time. // must not announce the same build a second time.
ServiceLocator.settings.markWhatsNewSeen(decision.version) ServiceLocator.settings.markWhatsNewSeen(
decision.version,
updateAlertAt = Instant.now().toString(),
)
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
Toast.makeText( Toast.makeText(
context, context,
@@ -1938,6 +1961,13 @@ private fun HomeScreen(
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) } var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) } var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) } var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
val displayedNotifications = listOfNotNull(
membyUpdateNotification(
settings.updateAlertVersion,
settings.updateAlertAt,
settings.updateAlertRead,
),
) + notificationState.notifications
var showNotifications by remember { mutableStateOf(false) } var showNotifications by remember { mutableStateOf(false) }
// Two different things: [launchingItem] is the gate that stops a second Play press // Two different things: [launchingItem] is the gate that stops a second Play press
// stacking a second player, and stays shut until one comes back. [resolvingItem] is // stacking a second player, and stays shut until one comes back. [resolvingItem] is
@@ -2396,7 +2426,7 @@ private fun HomeScreen(
} }
} }
}, },
alertCount = notificationState.notifications.size, alertCount = displayedNotifications.size,
activeUsername = settings.username.orEmpty(), activeUsername = settings.username.orEmpty(),
calendarEnabled = tvCalendarEnabled, calendarEnabled = tvCalendarEnabled,
) )
@@ -2408,7 +2438,7 @@ private fun HomeScreen(
) { ) {
val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
if (maintenanceMessage != null) { if (maintenanceMessage != null) {
// The rows are gone but the rail is not: Settings and Switch user are // The rows are gone but the rail is not: Settings and the active user are
// local, so there is no reason to strand the viewer here. // local, so there is no reason to strand the viewer here.
MaintenanceScreen( MaintenanceScreen(
message = maintenanceMessage, message = maintenanceMessage,
@@ -3037,7 +3067,7 @@ private fun HomeScreen(
navigationExpanded = false navigationExpanded = false
showProfiles = true showProfiles = true
}, },
alertCount = notificationState.notifications.size, alertCount = displayedNotifications.size,
onOpenAlerts = { onOpenAlerts = {
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "notifications", action = "open", screen = journeyScreen, category = "notifications", action = "open", screen = journeyScreen,
@@ -3086,7 +3116,7 @@ private fun HomeScreen(
} }
BackHandler(onBack = closeSettings) BackHandler(onBack = closeSettings)
// Settings is a destination, not a takeover: it leaves the main rail visible // Settings is a destination, not a takeover: it leaves the main rail visible
// and reachable so Home, Search and Switch user are one Left press away, the // and reachable so Home, Search and the active user are one Left press away, the
// same as from every other page. The rail below is the live one — this Row // same as from every other page. The rail below is the live one — this Row
// only reserves its collapsed footprint and takes the same expand shift the // only reserves its collapsed footprint and takes the same expand shift the
// content area does, so an expanded rail slides Settings aside rather than // content area does, so an expanded rail slides Settings aside rather than
@@ -3329,7 +3359,7 @@ private fun HomeScreen(
} }
BackHandler(onBack = closeAlerts) BackHandler(onBack = closeAlerts)
MyAlertsPage( MyAlertsPage(
notifications = notificationState.notifications, notifications = displayedNotifications,
preferences = notificationState.preferences, preferences = notificationState.preferences,
loading = notificationsLoading, loading = notificationsLoading,
errorMessage = notificationsError, errorMessage = notificationsError,
@@ -3373,7 +3403,11 @@ private fun HomeScreen(
// connection walking down the list and back up would send the same row's // connection walking down the list and back up would send the same row's
// request once per pass — clearing the flag immediately is what makes the // request once per pass — clearing the flag immediately is what makes the
// row stop asking. // row stop asking.
onRead = { notification -> onRead = onRead@{ notification ->
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.markUpdateAlertRead() }
return@onRead
}
val previousReadAt = notification.readAt val previousReadAt = notification.readAt
notificationState = notificationState.copy( notificationState = notificationState.copy(
notifications = notificationState.notifications.map { notifications = notificationState.notifications.map {
@@ -3401,8 +3435,12 @@ private fun HomeScreen(
// while its request was in flight is a row pressed again — which on this // while its request was in flight is a row pressed again — which on this
// page also re-aims where focus lands afterwards. A failure puts the row // page also re-aims where focus lands afterwards. A failure puts the row
// back where it was rather than quietly losing somebody's alert. // back where it was rather than quietly losing somebody's alert.
onDismiss = { notification -> onDismiss = onDismiss@{ notification ->
if (notificationsMutationBusy) return@MyAlertsPage if (notificationsMutationBusy) return@onDismiss
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.dismissUpdateAlert() }
return@onDismiss
}
notificationsMutationBusy = true notificationsMutationBusy = true
val previous = notificationState val previous = notificationState
notificationState = notificationState.copy( notificationState = notificationState.copy(
@@ -3428,8 +3466,10 @@ private fun HomeScreen(
notificationsMutationBusy = true notificationsMutationBusy = true
val previous = notificationState val previous = notificationState
val pending = notificationState.notifications.map(UserNotification::id) val pending = notificationState.notifications.map(UserNotification::id)
val dismissLocalUpdate = settings.updateAlertVersion != null
notificationState = notificationState.copy(notifications = emptyList()) notificationState = notificationState.copy(notifications = emptyList())
scope.launch { scope.launch {
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
val failed = pending.filter { id -> val failed = pending.filter { id ->
runCatching { repo.dismissNotification(id) }.isFailure runCatching { repo.dismissNotification(id) }.isFailure
} }
@@ -77,7 +77,7 @@ private const val RETRY_SECONDS = 30
/** /**
* Fills the content area while the gateway reports a deliberate outage. * Fills the content area while the gateway reports a deliberate outage.
* *
* The navigation rail deliberately stays mounted beside this: Settings and Switch user * The navigation rail deliberately stays mounted beside this: Settings and the active user
* are local, so there is no reason to strand the viewer just because content is * are local, so there is no reason to strand the viewer just because content is
* unavailable. Everything here is cheap to draw a handful of animated floats and plain * unavailable. Everything here is cheap to draw a handful of animated floats and plain
* Canvas geometry because TV GPUs punish blur and layered transparency. * Canvas geometry because TV GPUs punish blur and layered transparency.
@@ -7,10 +7,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -88,6 +90,32 @@ internal fun MembyPlayChip(
) )
} }
/**
* The circular Play treatment shown over a focused artwork card.
*
* The card itself remains the focusable and clickable node; this is only the visual cue,
* shared by the mini hero cards and the poster shelves so Play means one shape everywhere.
*/
@Composable
internal fun MembyArtworkPlayCue(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(38.dp)
.shadow(14.dp, CircleShape)
.clip(CircleShape)
.background(MembyAccent)
.border(2.dp, Color.White, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
@Composable @Composable
private fun PrimaryActionSurface( private fun PrimaryActionSurface(
label: String, label: String,
@@ -3,7 +3,7 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN } internal enum class UserSwitcherDirection { UP, DOWN }
/** /**
* Profiles occupy [0, profileCount); the pinned actions My Alerts, then Manage users * Profiles occupy [0, profileCount); the pinned actions Notifications, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation * follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic. * deterministic.
*/ */
@@ -1,7 +1,7 @@
package com.ponzischeme89.memby.ui.alerts package com.ponzischeme89.memby.ui.alerts
/** /**
* The wording and the counting behind My Alerts, kept pure so the badge a viewer sees in the * The wording and the counting behind Notifications, kept pure so the badge a viewer sees in the
* user picker and the summary line on the page itself are the same arithmetic tested once. * user picker and the summary line on the page itself are the same arithmetic tested once.
* *
* The badge answers "is there anything waiting for me", which is why it counts *alerts* and * The badge answers "is there anything waiting for me", which is why it counts *alerts* and
@@ -26,9 +26,9 @@ internal fun alertBadgeLabel(count: Int): String? = when {
*/ */
internal fun alertsSummary(total: Int, unread: Int): String = when { internal fun alertsSummary(total: Int, unread: Int): String = when {
total <= 0 -> "Nothing waiting for you" total <= 0 -> "Nothing waiting for you"
unread <= 0 -> if (total == 1) "1 alert" else "$total alerts" unread <= 0 -> if (total == 1) "1 notification" else "$total notifications"
else -> { else -> {
val alerts = if (total == 1) "1 alert" else "$total alerts" val notifications = if (total == 1) "1 notification" else "$total notifications"
"$alerts · $unread new" "$notifications · $unread new"
} }
} }
@@ -60,7 +60,7 @@ import com.ponzischeme89.memby.ui.theme.MembySurface
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/** /**
* My Alerts a full page rather than the panel it used to be, reached from the user menu in * Notifications a full page rather than the panel it used to be, reached from the user menu in
* the user picker. * the user picker.
* *
* It moved there because these alerts belong to a *person*, not to a television: they follow * It moved there because these alerts belong to a *person*, not to a television: they follow
@@ -150,7 +150,7 @@ fun MyAlertsPage(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MembyChoiceChip( MembyChoiceChip(
label = if (preferences.enabled) "Alerts on" else "Alerts off", label = if (preferences.enabled) "Notifications on" else "Notifications off",
selected = preferences.enabled, selected = preferences.enabled,
onClick = onToggleEnabled, onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester), modifier = Modifier.focusRequester(actionsFocusRequester),
@@ -177,7 +177,7 @@ fun MyAlertsPage(
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline)) Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (loading && !hasAlerts) { if (loading && !hasAlerts) {
AlertsNotice("Loading alerts…") AlertsNotice("Loading notifications…")
} else if (errorMessage != null && !hasAlerts) { } else if (errorMessage != null && !hasAlerts) {
AlertsNotice(errorMessage) AlertsNotice(errorMessage)
} else if (!hasAlerts) { } else if (!hasAlerts) {
@@ -232,7 +232,7 @@ private fun AlertsHeader(total: Int, unread: Int) {
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text("My Alerts", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp) Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
} }
} }
@@ -248,9 +248,9 @@ private fun AlertsEmptyState(enabled: Boolean) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
if (enabled) { if (enabled) {
"Alerts about the shows you follow will show up here." "Notifications about the shows you follow will show up here."
} else { } else {
"Alerts are switched off, so nothing new will arrive here." "Notifications are switched off, so nothing new will arrive here."
}, },
color = MembyQuietText, color = MembyQuietText,
fontSize = 14.sp, fontSize = 14.sp,
@@ -356,5 +356,6 @@ private fun AlertRow(
private fun alertIcon(kind: String): ImageVector = when { private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
kind.startsWith("show-", ignoreCase = true) -> Icons.Default.Tv
else -> Icons.Default.NotificationsActive else -> Icons.Default.NotificationsActive
} }
@@ -76,12 +76,16 @@ import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast import com.ponzischeme89.memby.data.resolveCast
import com.ponzischeme89.memby.data.selectSubtitleId import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.subtitleLabelWithFlag
import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.ui.MainActivity import com.ponzischeme89.memby.ui.MainActivity
import com.ponzischeme89.memby.ui.EmbyOutageBanner import com.ponzischeme89.memby.ui.EmbyOutageBanner
import com.ponzischeme89.memby.ui.ServiceAlertBanner import com.ponzischeme89.memby.ui.ServiceAlertBanner
@@ -104,6 +108,13 @@ import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil import kotlin.math.ceil
internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): String = when {
preference.mode == AudioPassthroughMode.AUTO -> "Auto"
preference.codecs.isEmpty() -> "Off"
preference.codecs.size == 1 -> preference.codecs.single().label
else -> "${preference.codecs.size} formats"
}
/** /**
* Fullscreen Media3 player with native stream-track selection. Press Menu while * Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button * playing to choose an audio or subtitle track; the subtitle controller button
@@ -3194,10 +3205,14 @@ class PlayerActivity : ComponentActivity() {
} }
// Cast is deliberately absent: it has its own button in the transport row now, and // Cast is deliberately absent: it has its own button in the transport row now, and
// one thing reachable two ways is one thing whose two entry points drift apart. // one thing reachable two ways is one thing whose two entry points drift apart.
val passthrough = ServiceLocator.settings.current.audioPassthroughPreference
val passthroughOption = "Soundbar / passthrough · ${passthroughOsdSummary(passthrough)}"
val pictureOption = "Picture size · ${selectedPictureMode().shortLabel}"
val options = buildList { val options = buildList {
if (audio) add("Audio") if (audio) add("Audio")
add("Subtitles & appearance") add("Subtitles & appearance")
add("Picture size · ${selectedPictureMode().shortLabel}") add(passthroughOption)
add(pictureOption)
} }
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options") .setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
@@ -3205,12 +3220,67 @@ class PlayerActivity : ComponentActivity() {
when (options[which]) { when (options[which]) {
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO) "Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
"Subtitles & appearance" -> showSubtitleOverlay() "Subtitles & appearance" -> showSubtitleOverlay()
else -> showPictureModePicker() passthroughOption -> showAudioPassthroughPicker()
pictureOption -> showPictureModePicker()
} }
} }
.show() .show()
} }
private fun showAudioPassthroughPicker() {
val current = ServiceLocator.settings.current.audioPassthroughPreference
val options = arrayOf("Auto detect", "Off", "Choose formats…")
val selected = when {
current.mode == AudioPassthroughMode.AUTO -> 0
current.codecs.isEmpty() -> 1
else -> 2
}
AlertDialog.Builder(this)
.setTitle("Soundbar / passthrough")
.setSingleChoiceItems(options, selected) { dialog, which ->
when (which) {
0 -> saveAudioPassthrough(AudioPassthroughMode.AUTO, current.codecs)
1 -> saveAudioPassthrough(AudioPassthroughMode.MANUAL, emptySet())
else -> {
dialog.dismiss()
showManualPassthroughPicker(current.codecs)
return@setSingleChoiceItems
}
}
dialog.dismiss()
}
.setNegativeButton("Cancel", null)
.show()
}
private fun showManualPassthroughPicker(initial: Set<SurroundCodec>) {
val selected = SurroundCodec.entries.map { it in initial }.toBooleanArray()
AlertDialog.Builder(this)
.setTitle("Formats accepted by your soundbar")
.setMultiChoiceItems(
SurroundCodec.entries.map(SurroundCodec::label).toTypedArray(),
selected,
) { _, which, checked -> selected[which] = checked }
.setPositiveButton("Save") { _, _ ->
saveAudioPassthrough(
AudioPassthroughMode.MANUAL,
SurroundCodec.entries.filterIndexed { index, _ -> selected[index] }.toSet(),
)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun saveAudioPassthrough(mode: AudioPassthroughMode, codecs: Set<SurroundCodec>) {
lifecycleScope.launch { ServiceLocator.settings.setAudioPassthrough(mode, codecs) }
Toast.makeText(
this,
"Soundbar setting saved. It applies when the next video starts.",
Toast.LENGTH_LONG,
).show()
playerView?.hideController()
}
private fun showPictureModePicker() { private fun showPictureModePicker() {
val selected = selectedPictureMode() val selected = selectedPictureMode()
AlertDialog.Builder(this) AlertDialog.Builder(this)
@@ -3483,7 +3553,7 @@ class PlayerActivity : ComponentActivity() {
discoveredTracks += TrackChoice( discoveredTracks += TrackChoice(
group.mediaTrackGroup, group.mediaTrackGroup,
index, index,
trackLabel(group.mediaTrackGroup, index), trackLabel(group.mediaTrackGroup, index, showSubtitleFlag = true),
) )
} }
} }
@@ -3609,7 +3679,10 @@ class PlayerActivity : ComponentActivity() {
subtitleCandidates.forEach { candidate -> subtitleCandidates.forEach { candidate ->
add( add(
SubtitleMenuEntry( SubtitleMenuEntry(
label = candidate.label.ifBlank { candidate.languageLabel }, label = subtitleLabelWithFlag(
candidate.language,
candidate.label.ifBlank { candidate.languageLabel },
),
selected = false, selected = false,
enabled = !subtitleRequestInFlight, enabled = !subtitleRequestInFlight,
), ),
@@ -3817,7 +3890,8 @@ class PlayerActivity : ComponentActivity() {
if (subtitle.isDefault) add("Default") if (subtitle.isDefault) add("Default")
if (subtitle.isHearingImpaired) add("SDH") if (subtitle.isHearingImpaired) add("SDH")
} }
return if (flags.isEmpty()) name else "$name · ${flags.joinToString(" · ")}" val label = if (flags.isEmpty()) name else "$name · ${flags.joinToString(" · ")}"
return subtitleLabelWithFlag(subtitle.language, label)
} }
private fun selectEncodedSubtitle(subtitle: PlayableSubtitle) { private fun selectEncodedSubtitle(subtitle: PlayableSubtitle) {
@@ -3933,7 +4007,7 @@ class PlayerActivity : ComponentActivity() {
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun trackLabel(group: TrackGroup, index: Int): String { private fun trackLabel(group: TrackGroup, index: Int, showSubtitleFlag: Boolean = false): String {
val format = group.getFormat(index) val format = group.getFormat(index)
val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() } val rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() }
val meaningfulLabel = rawLabel?.takeUnless { it.matches(Regex("""^\([^)]*\)$""")) } val meaningfulLabel = rawLabel?.takeUnless { it.matches(Regex("""^\([^)]*\)$""")) }
@@ -3948,7 +4022,8 @@ class PlayerActivity : ComponentActivity() {
if (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0) add("Default") if (format.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0) add("Default")
if (format.roleFlags and C.ROLE_FLAG_CAPTION != 0) add("CC") if (format.roleFlags and C.ROLE_FLAG_CAPTION != 0) add("CC")
} }
return if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}" val label = if (traits.isEmpty()) name else "$name · ${traits.joinToString(" · ")}"
return if (showSubtitleFlag) subtitleLabelWithFlag(format.language, label) else label
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
@@ -39,7 +40,7 @@ data class SubtitleDownloadState(
val entries: List<SubtitleMenuEntry> = emptyList(), val entries: List<SubtitleMenuEntry> = emptyList(),
/** /**
* Whether the panel has switched to being *about* downloading, which hides the track * Whether the panel has switched to being *about* downloading, which hides the track
* list and the text-size chips. It is not decoration: the drop-up is 344dp wide by * list and the text-size chips. It is not decoration: the drop-up is 316dp wide by
* about a third of a 720p screen, and holding a track list, a size row and a list of * about a third of a 720p screen, and holding a track list, a size row and a list of
* search results at once squeezed the tracks down to a single visible row. One question * search results at once squeezed the tracks down to a single visible row. One question
* at a time, and Back is what steps out of this one. * at a time, and Back is what steps out of this one.
@@ -99,6 +100,9 @@ fun bindSubtitleMenu(
tracks.forEachIndexed { index, entry -> tracks.forEachIndexed { index, entry ->
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) }) trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
} }
overlay.findViewById<ScrollView>(R.id.player_subtitle_tracks_scroll)?.apply {
layoutParams = layoutParams.apply { height = menuRowsHeight(context, tracks.size, 3) }
}
sizes.forEachIndexed { index, entry -> sizes.forEachIndexed { index, entry ->
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) }) sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
} }
@@ -128,6 +132,16 @@ private fun bindDownloadSection(
downloads.entries.forEachIndexed { index, entry -> downloads.entries.forEachIndexed { index, entry ->
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) }) container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
} }
overlay.findViewById<ScrollView>(R.id.player_subtitle_downloads_scroll)?.apply {
layoutParams = layoutParams.apply {
height = menuRowsHeight(overlay.context, downloads.entries.size, 4)
}
}
}
private fun menuRowsHeight(context: Context, rows: Int, maximumRows: Int): Int {
val density = context.resources.displayMetrics.density
return (rows.coerceIn(1, maximumRows) * 42 * density).toInt()
} }
/** /**
@@ -145,7 +159,7 @@ private fun subtitleMenuOption(
fun dp(value: Int) = (value * density).toInt() fun dp(value: Int) = (value * density).toInt()
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT, if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 40 else 46), dp(if (chip) 36 else 40),
).apply { ).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2) if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
} }
@@ -159,11 +173,11 @@ private fun subtitleMenuOption(
setTextColor(context.getColorStateList(R.color.player_overlay_option_text)) setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
} }
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(14), 0) setPadding(dp(12), 0, dp(12), 0)
text = entry.label text = entry.label
maxLines = 1 maxLines = 1
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
textSize = if (chip) 14f else 15f textSize = if (chip) 13f else 14f
typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL) typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL)
// A disabled row keeps its place and its label and only stops being reachable, so a // A disabled row keeps its place and its label and only stops being reachable, so a
// search in flight cannot be started twice and nothing moves while it runs. // search in flight cannot be started twice and nothing moves while it runs.
@@ -128,6 +128,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
internal data class ChoiceOption(val value: String, val label: String, val color: Color? = null) internal data class ChoiceOption(val value: String, val label: String, val color: Color? = null)
@@ -1151,10 +1155,17 @@ internal fun deviceDescription(device: GatewayDevice): String = buildList {
if (device.current) add("This TV") if (device.current) add("This TV")
device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") } device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
device.lastSeenAt.takeIf(String::isNotBlank)?.let { device.lastSeenAt.takeIf(String::isNotBlank)?.let {
add("Last active ${it.replace('T', ' ').substringBefore('.').removeSuffix("Z")}") add("Last active ${formatDeviceLastSeen(it)}")
} }
}.joinToString("").ifBlank { "Signed in" } }.joinToString("").ifBlank { "Signed in" }
internal fun formatDeviceLastSeen(raw: String, zoneId: ZoneId = ZoneId.systemDefault()): String =
runCatching {
DateTimeFormatter.ofPattern("d MMM yyyy, h:mm a")
.withLocale(Locale.forLanguageTag("en-NZ"))
.format(Instant.parse(raw.trim()).atZone(zoneId))
}.getOrDefault(raw)
@Composable @Composable
private fun DeviceManagementRow( private fun DeviceManagementRow(
device: GatewayDevice, device: GatewayDevice,
@@ -1994,7 +2005,7 @@ private fun ReleaseHistoryRow(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let { release.date.takeIf(String::isNotBlank)?.let {
Text(it, color = TextQuiet, fontSize = 12.sp) Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
} }
Text( Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES", if (expanded) "HIDE" else "${release.changes.size} CHANGES",
@@ -13,6 +13,22 @@ internal data class ReleaseNote(
val changes: List<String>, val changes: List<String>,
) )
private val ReleaseMonths = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
/** Turns the changelog's wire-friendly ISO date into the day-first date shown in About. */
internal fun formatReleaseDate(raw: String): String {
val date = raw.trim()
if (date.length != 10 || date[4] != '-' || date[7] != '-') return raw
val year = date.substring(0, 4).toIntOrNull() ?: return raw
val month = date.substring(5, 7).toIntOrNull() ?: return raw
val day = date.substring(8, 10).toIntOrNull() ?: return raw
if (month !in 1..12 || day !in 1..31) return raw
return "$day ${ReleaseMonths[month - 1]} $year"
}
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""") private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
/** /**
@@ -6,7 +6,7 @@
what caps how tall the track list can grow before it scrolls. what caps how tall the track list can grow before it scrolls.
The panel holds two screens, not three sections. Choosing a track and fetching one the The panel holds two screens, not three sections. Choosing a track and fetching one the
title does not have are separate questions, and at 344dp by a third of a 720p screen title does not have are separate questions, and at 316dp by a third of a 720p screen
there is not room to ask both — stacking them squeezed the track list to a single row. there is not room to ask both — stacking them squeezed the track list to a single row.
So `player_subtitle_main_section` and the results half of the download section swap: So `player_subtitle_main_section` and the results half of the download section swap:
the search row is the way in, Back is the way out, one press per level. --> the search row is the way in, Back is the way out, one press per level. -->
@@ -21,7 +21,7 @@
<LinearLayout <LinearLayout
android:id="@+id/player_subtitle_menu" android:id="@+id/player_subtitle_menu"
android:layout_width="344dp" android:layout_width="316dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:layout_marginTop="96dp" android:layout_marginTop="96dp"
@@ -30,9 +30,9 @@
android:background="@drawable/player_menu_background" android:background="@drawable/player_menu_background"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="10dp" android:paddingStart="10dp"
android:paddingTop="14dp" android:paddingTop="10dp"
android:paddingEnd="10dp" android:paddingEnd="10dp"
android:paddingBottom="12dp"> android:paddingBottom="10dp">
<LinearLayout <LinearLayout
android:id="@+id/player_subtitle_main_section" android:id="@+id/player_subtitle_main_section"
@@ -67,10 +67,10 @@
android:visibility="gone" /> android:visibility="gone" />
<ScrollView <ScrollView
android:id="@+id/player_subtitle_tracks_scroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="6dp"
android:layout_weight="1"
android:fillViewport="false" android:fillViewport="false"
android:overScrollMode="never" android:overScrollMode="never"
android:scrollbars="none"> android:scrollbars="none">
@@ -86,7 +86,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginStart="14dp" android:layout_marginStart="14dp"
android:layout_marginTop="10dp" android:layout_marginTop="7dp"
android:layout_marginEnd="14dp" android:layout_marginEnd="14dp"
android:background="#14FFFFFF" /> android:background="#14FFFFFF" />
@@ -94,7 +94,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginStart="14dp"
android:layout_marginTop="12dp" android:layout_marginTop="8dp"
android:letterSpacing="0.12" android:letterSpacing="0.12"
android:text="@string/player_text_size" android:text="@string/player_text_size"
android:textColor="#7AFFFFFF" android:textColor="#7AFFFFFF"
@@ -105,7 +105,7 @@
android:id="@+id/player_subtitle_sizes" android:id="@+id/player_subtitle_sizes"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="6dp"
android:orientation="horizontal" /> android:orientation="horizontal" />
</LinearLayout> </LinearLayout>
@@ -115,7 +115,6 @@
android:id="@+id/player_subtitle_download_section" android:id="@+id/player_subtitle_download_section"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone"> android:visibility="gone">
@@ -127,7 +126,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginStart="14dp" android:layout_marginStart="14dp"
android:layout_marginTop="12dp" android:layout_marginTop="8dp"
android:layout_marginEnd="14dp" android:layout_marginEnd="14dp"
android:background="#14FFFFFF" /> android:background="#14FFFFFF" />
@@ -135,7 +134,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="14dp" android:layout_marginStart="14dp"
android:layout_marginTop="12dp" android:layout_marginTop="8dp"
android:letterSpacing="0.12" android:letterSpacing="0.12"
android:text="@string/player_subtitle_download" android:text="@string/player_subtitle_download"
android:textColor="#7AFFFFFF" android:textColor="#7AFFFFFF"
@@ -154,10 +153,10 @@
android:visibility="gone" /> android:visibility="gone" />
<ScrollView <ScrollView
android:id="@+id/player_subtitle_downloads_scroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="6dp"
android:layout_weight="1"
android:fillViewport="false" android:fillViewport="false"
android:overScrollMode="never" android:overScrollMode="never"
android:scrollbars="none"> android:scrollbars="none">
@@ -32,6 +32,14 @@ class SubtitlePreferenceTest {
assertEquals("klingon", normalizeSubtitleLanguage("Klingon")) assertEquals("klingon", normalizeSubtitleLanguage("Klingon"))
} }
@Test
fun `subtitle labels include a country flag when the language is known`() {
assertEquals("🇮🇹 Italian", subtitleLabelWithFlag("ita", "Italian"))
assertEquals("🇬🇧 English", subtitleLabelWithFlag("en-GB", "English"))
assertEquals("🇧🇷 Português", subtitleLabelWithFlag("pt-BR", "Português"))
assertEquals("Klingon", subtitleLabelWithFlag("tlh", "Klingon"))
}
@Test @Test
fun `subtitles turned off means nothing is selected`() { fun `subtitles turned off means nothing is selected`() {
val tracks = listOf(track("1", "eng", default = true)) val tracks = listOf(track("1", "eng", default = true))
@@ -16,4 +16,10 @@ class ProfileInitialsTest {
assertEquals("AL", profileInitials("alwyn")) assertEquals("AL", profileInitials("alwyn"))
assertEquals("?", profileInitials(" ")) assertEquals("?", profileInitials(" "))
} }
@Test
fun `profile destination names the signed in viewer`() {
assertEquals("Matt Cohen", profileDestinationLabel(" Matt Cohen "))
assertEquals("User", profileDestinationLabel(" "))
}
} }
@@ -50,7 +50,7 @@ class UserSwitcherNavigationTest {
fun `both pinned actions are reachable below the profiles`() { fun `both pinned actions are reachable below the profiles`() {
val profileCount = 3 val profileCount = 3
// …the last profile, then My Alerts, then Manage users, and no further. // …the last profile, then Notifications, then Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2)) assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2)) assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2)) assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
@@ -31,11 +31,11 @@ class AlertsFormatTest {
} }
@Test @Test
fun `the summary counts alerts, and names the new ones only when there are some`() { fun `the summary counts notifications, and names the new ones only when there are some`() {
assertEquals("Nothing waiting for you", alertsSummary(total = 0, unread = 0)) assertEquals("Nothing waiting for you", alertsSummary(total = 0, unread = 0))
assertEquals("1 alert", alertsSummary(total = 1, unread = 0)) assertEquals("1 notification", alertsSummary(total = 1, unread = 0))
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0)) assertEquals("4 notifications", alertsSummary(total = 4, unread = 0))
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2)) assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1)) assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1))
} }
} }
@@ -16,7 +16,7 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
/** /**
* Renders My Alerts to PNGs under `build/screenshots/my-alerts/`, so the page can be looked * Renders Notifications to PNGs under `build/screenshots/my-alerts/`, so the page can be looked
* at without deploying to a TV. * at without deploying to a TV.
* *
* ```powershell * ```powershell
@@ -0,0 +1,36 @@
package com.ponzischeme89.memby.ui.player
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec
import org.junit.Assert.assertEquals
import org.junit.Test
class AudioPassthroughOsdTest {
@Test
fun `summary distinguishes automatic off and manual formats`() {
assertEquals("Auto", passthroughOsdSummary(AudioPassthroughPreference.AUTOMATIC))
assertEquals(
"Off",
passthroughOsdSummary(AudioPassthroughPreference(AudioPassthroughMode.MANUAL)),
)
assertEquals(
"Dolby Digital",
passthroughOsdSummary(
AudioPassthroughPreference(
AudioPassthroughMode.MANUAL,
setOf(SurroundCodec.AC3),
),
),
)
assertEquals(
"2 formats",
passthroughOsdSummary(
AudioPassthroughPreference(
AudioPassthroughMode.MANUAL,
setOf(SurroundCodec.AC3, SurroundCodec.DTS),
),
),
)
}
}
@@ -41,9 +41,9 @@ class SubtitleMenuScreenshotTest {
name = "subtitles-menu-track-selected", name = "subtitles-menu-track-selected",
tracks = listOf( tracks = listOf(
entry("Off"), entry("Off"),
entry("English", selected = true), entry("🇬🇧 English", selected = true),
entry("English · SDH"), entry("🇬🇧 English · SDH"),
entry("Italian · Forced"), entry("🇮🇹 Italian · Forced"),
), ),
focused = 1, focused = 1,
) )
@@ -54,7 +54,7 @@ class SubtitleMenuScreenshotTest {
fun `subtitles off`() { fun `subtitles off`() {
capture( capture(
name = "subtitles-menu-off", name = "subtitles-menu-off",
tracks = listOf(entry("Off", selected = true), entry("English"), entry("Français")), tracks = listOf(entry("Off", selected = true), entry("🇬🇧 English"), entry("🇫🇷 Français")),
focused = 0, focused = 0,
) )
} }
@@ -64,7 +64,7 @@ class SubtitleMenuScreenshotTest {
fun `text size focused`() { fun `text size focused`() {
capture( capture(
name = "subtitles-menu-text-size", name = "subtitles-menu-text-size",
tracks = listOf(entry("Off"), entry("English", selected = true)), tracks = listOf(entry("Off"), entry("🇬🇧 English", selected = true)),
focusedSize = 2, focusedSize = 2,
) )
} }
@@ -79,14 +79,14 @@ class SubtitleMenuScreenshotTest {
name = "subtitles-menu-long-list", name = "subtitles-menu-long-list",
tracks = listOf( tracks = listOf(
entry("Off"), entry("Off"),
entry("English", selected = true), entry("🇬🇧 English", selected = true),
entry("English · SDH"), entry("🇬🇧 English · SDH"),
entry("Français"), entry("🇫🇷 Français"),
entry("Deutsch · Forced"), entry("🇩🇪 Deutsch · Forced"),
entry("Italiano"), entry("🇮🇹 Italiano"),
entry("Español · Default"), entry("🇪🇸 Español · Default"),
entry("Português (Brasil)"), entry("🇧🇷 Português (Brasil)"),
entry("Nederlands · SDH · Burned in"), entry("🇳🇱 Nederlands · SDH · Burned in"),
), ),
focused = 1, focused = 1,
) )
@@ -101,7 +101,7 @@ class SubtitleMenuScreenshotTest {
fun `download offered with nothing suitable in the list`() { fun `download offered with nothing suitable in the list`() {
capture( capture(
name = "subtitles-menu-download-offered", name = "subtitles-menu-download-offered",
tracks = listOf(entry("Off", selected = true), entry("Deutsch")), tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
downloads = SubtitleDownloadState( downloads = SubtitleDownloadState(
available = true, available = true,
entries = listOf(entry("Search for subtitles…", prominent = true)), entries = listOf(entry("Search for subtitles…", prominent = true)),
@@ -134,16 +134,16 @@ class SubtitleMenuScreenshotTest {
fun `download results`() { fun `download results`() {
capture( capture(
name = "subtitles-menu-download-results", name = "subtitles-menu-download-results",
tracks = listOf(entry("Off", selected = true), entry("Deutsch")), tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
downloads = SubtitleDownloadState( downloads = SubtitleDownloadState(
available = true, available = true,
expanded = true, expanded = true,
entries = listOf( entries = listOf(
entry("Search again", prominent = true), entry("Search again", prominent = true),
entry("English · 98% match"), entry("🇬🇧 English · 98% match"),
entry("English · Hearing impaired · 96% match"), entry("🇬🇧 English · Hearing impaired · 96% match"),
entry("English · Forced · 91% match"), entry("🇬🇧 English · Forced · 91% match"),
entry("Italian · 84% match"), entry("🇮🇹 Italian · 84% match"),
), ),
), ),
focusedDownload = 1, focusedDownload = 1,
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui.settings
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.time.ZoneId
class VersionHistoryTest { class VersionHistoryTest {
@@ -69,4 +70,19 @@ class VersionHistoryTest {
releases.map { it.version }, releases.map { it.version },
) )
} }
@Test
fun `release dates are shown day first`() {
assertEquals("11 Aug 2026", formatReleaseDate("2026-08-11"))
assertEquals("unknown", formatReleaseDate("unknown"))
}
@Test
fun `device activity is shown in local day first format`() {
assertEquals(
"11 Aug 2026, 10:05 pm",
formatDeviceLastSeen("2026-08-11T10:05:00Z", ZoneId.of("Pacific/Auckland")),
)
assertEquals("not-a-date", formatDeviceLastSeen("not-a-date", ZoneId.of("UTC")))
}
} }
@@ -1,6 +1,10 @@
package com.ponzischeme89.memby.ui.whatsnew package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.MEMBY_UPDATE_NOTIFICATION_ID
import com.ponzischeme89.memby.ui.membyUpdateNotification
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class WhatsNewTest { class WhatsNewTest {
@@ -49,4 +53,14 @@ class WhatsNewTest {
assertEquals(WhatsNewDecision.Nothing, decide(installed = " ")) assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
} }
@Test
fun `an installed update becomes a local Notifications entry`() {
val alert = membyUpdateNotification("0.2.52", "2026-08-11T10:00:00Z", read = false)!!
assertEquals(MEMBY_UPDATE_NOTIFICATION_ID, alert.id)
assertEquals("Memby updated", alert.title)
assertEquals("This TV is now running Memby 0.2.52.", alert.message)
assertFalse(alert.readAt != null)
assertNull(membyUpdateNotification(" ", null, read = false))
}
} }
+32 -8
View File
@@ -13,8 +13,8 @@ The remote deployment:
- installs .env from the local .env.example, overwriting the deployed - installs .env from the local .env.example, overwriting the deployed
copy, and keeps the old one alongside as .env.previous; copy, and keeps the old one alongside as .env.previous;
- preserves the named database volume; - preserves the named database volume;
- tells every signed-in television a deployment is starting, through the - unless --Quiet is supplied, tells every signed-in television a deployment
gateway that is still running, before the build begins; is starting through the gateway that is still running before the build begins;
- pulls Redis and PostgreSQL, then builds the Memby server; - pulls Redis and PostgreSQL, then builds the Memby server;
- starts each service and waits for its health check; - starts each service and waits for its health check;
- restores the previous application files if activation fails. - restores the previous application files if activation fails.
@@ -38,6 +38,9 @@ This deploys the current local working tree, including uncommitted server change
.EXAMPLE .EXAMPLE
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m .\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
.EXAMPLE
.\deploy-server.ps1 --Quiet
#> #>
#Requires -Version 7.2 #Requires -Version 7.2
@@ -82,16 +85,27 @@ param(
[Alias('m')] [Alias('m')]
[switch] $MandatoryUpdate, [switch] $MandatoryUpdate,
# PowerShell advanced scripts do not bind GNU-style double-dash switches. Accept [Parameter()]
# --m explicitly as the sole positional argument so it can still sit at the end. [switch] $Quiet,
# PowerShell advanced scripts do not bind GNU-style double-dash switches by name.
# Accept the two documented flags in either positional order instead.
[Parameter(Position = 0)] [Parameter(Position = 0)]
[ValidateSet('--m')] [string] $TrailingFlag0,
[string] $MandatoryFlag
[Parameter(Position = 1)]
[string] $TrailingFlag1
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$mandatoryRelease = $MandatoryUpdate -or $MandatoryFlag -eq '--m' $trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') })
if ($unknownFlags.Count -gt 0) {
throw "Unknown deployment option: $($unknownFlags -join ', ')"
}
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
if ($mandatoryRelease -and $SkipAppRelease) { if ($mandatoryRelease -and $SkipAppRelease) {
throw '--m cannot be combined with -SkipAppRelease because no update would be published.' throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
@@ -108,6 +122,9 @@ function Write-Banner {
if ($mandatoryRelease) { if ($mandatoryRelease) {
Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow Write-Host '│ Update mandatory (viewers cannot skip it)' -ForegroundColor Yellow
} }
if ($quietDeployment) {
Write-Host '│ Notice quiet (no advance television announcement)' -ForegroundColor DarkGray
}
Write-Host '╰─' -ForegroundColor Magenta Write-Host '╰─' -ForegroundColor Magenta
Write-Host '' Write-Host ''
} }
@@ -534,6 +551,7 @@ destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__ health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__ publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__ mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
parent=$(dirname "$destination") parent=$(dirname "$destination")
staging="${destination}.new.$$" staging="${destination}.new.$$"
backup="${destination}.previous.$$" backup="${destination}.previous.$$"
@@ -750,7 +768,9 @@ step '[remote 4/10] Telling the televisions'
# #
# Entirely best-effort: a deployment must never fail over a banner, and a first # Entirely best-effort: a deployment must never fail over a banner, and a first
# deployment (or one onto a host with no curl) has nothing to announce with. # deployment (or one onto a host with no curl) has nothing to announce with.
if [ -z "$previous_admin_token" ]; then if [ "$quiet_deployment" -eq 1 ]; then
detail 'Quiet deployment; skipping the television announcement'
elif [ -z "$previous_admin_token" ]; then
detail 'No previous release to announce from; skipping' detail 'No previous release to announce from; skipping'
elif ! command -v curl >/dev/null 2>&1; then elif ! command -v curl >/dev/null 2>&1; then
detail 'curl is unavailable on the NAS; skipping the announcement' detail 'curl is unavailable on the NAS; skipping the announcement'
@@ -895,6 +915,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
'__MANDATORY_UPDATE__', '__MANDATORY_UPDATE__',
$(if ($mandatoryRelease) { '1' } else { '0' }) $(if ($mandatoryRelease) { '1' } else { '0' })
) )
$remoteCommand = $remoteCommand.Replace(
'__QUIET_DEPLOYMENT__',
$(if ($quietDeployment) { '1' } else { '0' })
)
# This file is edited on Windows, so the here-string above arrives with whatever line # This file is edited on Windows, so the here-string above arrives with whatever line
# endings it was saved with. A remote shell reads a trailing carriage return as part # endings it was saved with. A remote shell reads a trailing carriage return as part
# of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the # of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
+3
View File
@@ -190,6 +190,9 @@ func run(log *slog.Logger, events *logging.Buffer) error {
go server.WatchMaintenance(ctx, 30*time.Second) go server.WatchMaintenance(ctx, 30*time.Second)
// One probe per gateway, not per TV: the answer is the same for the whole house. // One probe per gateway, not per TV: the answer is the same for the whole house.
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval) go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
// One Sonarr catalogue reading per day records lifecycle changes for the household and
// materialises cancellation notifications for every known viewer.
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
if err := server.LoadUpdatePolicy(ctx); err != nil { if err := server.LoadUpdatePolicy(ctx); err != nil {
return err return err
+4
View File
@@ -68,6 +68,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy)) mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy)) mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings)) mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings)) mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest)) mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
@@ -264,6 +266,7 @@ type adminStatus struct {
ForYouRunning bool `json:"forYouRunning"` ForYouRunning bool `json:"forYouRunning"`
RequestPolicy store.RequestPolicy `json:"requestPolicy"` RequestPolicy store.RequestPolicy `json:"requestPolicy"`
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"` PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
HeroPolicy heroAdminPolicy `json:"heroPolicy"`
MDBList mdblistAdminSettings `json:"mdblist"` MDBList mdblistAdminSettings `json:"mdblist"`
Subtitles subtitleAdminSettings `json:"subtitles"` Subtitles subtitleAdminSettings `json:"subtitles"`
Features featureResponse `json:"features"` Features featureResponse `json:"features"`
@@ -329,6 +332,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
} }
return policy return policy
}(), }(),
HeroPolicy: s.heroAdminPolicy(ctx),
Subtitles: s.subtitleAdminSettings(ctx), Subtitles: s.subtitleAdminSettings(ctx),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion), Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers, RequestUsers: requestUsers,
+30
View File
@@ -0,0 +1,30 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="star" data-icon-tone="note">Pinned titles</h2>
<p class="card-note">Pinned films and series lead the four-card launcher grid in this order. Empty
places are filled by Membys existing mix of recent digital releases, premieres and
highly rated library titles. Pinning changes placement only; labels and reasons remain natural.</p>
</div>
<div id="hero-pins"></div>
<label class="field"><span>Prime-card subtitle</span>
<em>Optional wording under the large first card. Leave blank to use Membys natural release or rating reason.</em>
<input id="hero-prime-subtitle" type="text" maxlength="160"
placeholder="Leave blank for the automatic reason"></label>
<div class="card-foot">
<button class="primary" id="hero-save">Save hero</button>
<button id="hero-clear">Clear pins</button>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="search" data-icon-tone="info">Find a title</h2>
<p class="card-note">Search the imported Emby catalogue. Up to four films or series can be pinned.</p>
</div>
<div class="row">
<label class="field"><span>Title</span>
<input id="hero-query" type="search" placeholder="Search films and television shows"></label>
<button id="hero-search">Search</button>
</div>
<div class="grid" id="hero-results"></div>
</section>
+75
View File
@@ -0,0 +1,75 @@
const { fmt, ui, $ } = Admin;
let pins = [];
let pinsDirty = false;
function renderPins() {
$('hero-pins').innerHTML = pins.length
? '<div class="chips">' + pins.map((item, index) =>
'<button class="quiet small" data-remove="' + fmt.escape(item.id) + '">' +
(index + 1) + '. ' + fmt.escape(item.name) + (item.year ? ' (' + item.year + ')' : '') +
' · remove</button>').join('') + '</div>'
: ui.empty('No titles are pinned. The hero is entirely release-aware and automatic.');
for (const button of $('hero-pins').querySelectorAll('[data-remove]')) {
button.addEventListener('click', () => {
pins = pins.filter((item) => item.id !== button.dataset.remove);
pinsDirty = true;
renderPins();
});
}
}
Admin.onStatus((status) => {
if (pinsDirty || !Admin.settled($('hero-pins'))) return;
pins = (status.heroPolicy?.pinnedItems || []).slice(0, 4);
Admin.fill($('hero-prime-subtitle'), status.heroPolicy?.primeSubtitle || '');
renderPins();
});
async function search() {
const query = $('hero-query').value.trim();
if (!query) return;
const payload = await Admin.api('/admin/api/hero/search?q=' + encodeURIComponent(query));
const items = payload.items || [];
$('hero-results').innerHTML = items.length ? items.map((item) =>
'<section class="card"><h2 class="card-title">' + fmt.escape(item.name) + '</h2>' +
'<p class="card-note">' + fmt.escape(item.type || 'Title') + ' · ' +
(item.year || 'Year unknown') + '</p>' +
'<button data-add="' + fmt.escape(item.id) + '">Add to hero</button></section>').join('')
: ui.empty('No playable films or series matched that search.');
for (const button of $('hero-results').querySelectorAll('[data-add]')) {
button.addEventListener('click', () => {
const selected = items.find((item) => item.id === button.dataset.add);
if (!selected || pins.some((item) => item.id === selected.id)) return;
if (pins.length >= 4) {
Admin.error('Remove a pinned film before adding another.');
return;
}
pins.push(selected);
pinsDirty = true;
renderPins();
Admin.error('');
});
}
}
Admin.ready(() => {
$('hero-search').addEventListener('click', () => Admin.act(search));
$('hero-query').addEventListener('keydown', (event) => {
if (event.key === 'Enter') Admin.act(search);
});
$('hero-save').addEventListener('click', () => Admin.act(async () => {
await Admin.api('/admin/api/hero-policy', {
method: 'POST', body: JSON.stringify({
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: $('hero-prime-subtitle').value.trim(),
}),
});
pinsDirty = false;
}));
$('hero-clear').addEventListener('click', () => {
pins = [];
pinsDirty = true;
renderPins();
});
$('hero-prime-subtitle').addEventListener('input', () => { pinsDirty = true; });
});
+5
View File
@@ -112,6 +112,11 @@ var adminNav = []adminNavGroup{
{ {
Label: "Experience", Label: "Experience",
Items: []adminNavItem{ Items: []adminNavItem{
{
ID: "hero", Label: "Home hero", Title: "Home hero",
Intro: "Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",
Icon: "m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z",
},
{ {
ID: "features", Label: "Features", Title: "Features", ID: "features", Label: "Features", Title: "Features",
Intro: "Roll out, stop and recover optional behaviour with no app release.", Intro: "Roll out, stop and recover optional behaviour with no app release.",
+100 -3
View File
@@ -57,6 +57,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
) )
const ( const (
@@ -134,6 +135,7 @@ type heroKind int
const ( const (
heroMovie heroKind = iota heroMovie heroKind = iota
heroSeries
heroSeriesPremiere heroSeriesPremiere
heroSeasonPremiere heroSeasonPremiere
) )
@@ -345,6 +347,8 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location) return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeriesPremiere: case candidate.Kind == heroSeriesPremiere:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location) return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeries && fresh:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeasonPremiere: case candidate.Kind == heroSeasonPremiere:
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location) return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
case fresh && acclaimed && candidate.Estimated: case fresh && acclaimed && candidate.Estimated:
@@ -363,6 +367,19 @@ func heroReason(candidate heroCandidate, now time.Time, location *time.Location)
} }
} }
func heroReasonForPosition(
candidate heroCandidate,
position int,
primeSubtitle string,
now time.Time,
location *time.Location,
) string {
if position == 0 && strings.TrimSpace(primeSubtitle) != "" {
return strings.TrimSpace(primeSubtitle)
}
return heroReason(candidate, now, location)
}
// heroWhen words a date the way somebody would say it out loud. Nothing here is more // heroWhen words a date the way somebody would say it out loud. Nothing here is more
// precise than the evidence: a digital release date carries no time of day, so a card // precise than the evidence: a digital release date carries no time of day, so a card
// never claims an hour. // never claims an hour.
@@ -648,23 +665,30 @@ func (s *Server) heroRow(
location = time.Local location = time.Local
} }
candidates := s.heroCandidates(ctx, rows, now) candidates := s.heroCandidates(ctx, rows, now)
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
policy = store.HeroPolicy{}
}
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is // Rank a pool, then draw the row out of it. Ranking straight to the row's length is
// what made the hero the same four cards for a week — see rotateHeroCandidates. // what made the hero the same four cards for a week — see rotateHeroCandidates.
pool := rankHeroCandidates(candidates, now, heroPoolLimit) pool := rankHeroCandidates(candidates, now, heroPoolLimit)
ranked := rotateHeroCandidates( organic := rotateHeroCandidates(
pool, pool,
heroVariationSeed(userID, heroRotationSlot(now, location)), heroVariationSeed(userID, heroRotationSlot(now, location)),
heroRowLimit, heroRowLimit,
) )
ranked := mergePinnedHeroCandidates(pinned, candidates, organic, heroRowLimit)
if len(ranked) == 0 { if len(ranked) == 0 {
return nil return nil
} }
items := make([]json.RawMessage, 0, len(ranked)) items := make([]json.RawMessage, 0, len(ranked))
for _, candidate := range ranked { for index, candidate := range ranked {
items = append(items, injectHeroFields( items = append(items, injectHeroFields(
candidate.Item, candidate.Item,
heroLabel(candidate, now), heroLabel(candidate, now),
heroReason(candidate, now, location), heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
)) ))
} }
return &recommend.Row{ return &recommend.Row{
@@ -675,6 +699,79 @@ func (s *Server) heroRow(
} }
} }
// mergePinnedHeroCandidates places explicit operator choices in the visible grid first,
// then fills it with the normal release-aware rotation. When a pin also qualified
// organically, its release or premiere evidence is retained so pinning changes placement,
// never presentation.
func mergePinnedHeroCandidates(
pinned, evidence, organic []heroCandidate,
limit int,
) []heroCandidate {
if limit <= 0 {
return nil
}
out := make([]heroCandidate, 0, limit)
seen := map[string]bool{}
evidenceByID := make(map[string]heroCandidate, len(evidence))
for _, candidate := range evidence {
evidenceByID[candidate.ID] = candidate
}
for index, candidate := range pinned {
if natural, ok := evidenceByID[candidate.ID]; ok {
pinned[index] = natural
}
}
appendUnique := func(candidates []heroCandidate) {
for _, candidate := range candidates {
if len(out) == limit {
return
}
if candidate.ID == "" || len(candidate.Item) == 0 || seen[candidate.ID] {
continue
}
seen[candidate.ID] = true
out = append(out, candidate)
}
}
appendUnique(pinned)
appendUnique(organic)
return out
}
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
items, err := s.store.LibraryItemsByID(ctx, ids)
if err != nil {
s.loggerFor(ctx).Warn("pinned hero titles unavailable", "error", err)
return nil
}
byID := make(map[string]heroCandidate, len(items))
for _, raw := range items {
fact, ok := heroFactsOf(raw)
if !ok || !fact.Playable ||
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
continue
}
rating, rated := heroRatingOf(raw)
kind := heroMovie
if strings.EqualFold(fact.Type, "Series") {
kind = heroSeries
}
byID[fact.ID] = heroCandidate{
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated,
}
}
out := make([]heroCandidate, 0, len(ids))
for _, id := range ids {
if candidate, ok := byID[id]; ok {
out = append(out, candidate)
}
}
return out
}
// heroCandidates gathers everything eligible, premieres first. // heroCandidates gathers everything eligible, premieres first.
// //
// Premieres lead the input order so that they win a tie against a film of identical // Premieres lead the input order so that they win a tie against a film of identical
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
type heroAdminItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Year int `json:"year,omitempty"`
}
type heroAdminPolicy struct {
PinnedItems []heroAdminItem `json:"pinnedItems"`
PrimeSubtitle string `json:"primeSubtitle"`
}
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
fact, ok := heroFactsOf(raw)
if !ok || !fact.Playable ||
(!strings.EqualFold(fact.Type, "Movie") && !strings.EqualFold(fact.Type, "Series")) {
return heroAdminItem{}, false
}
return heroAdminItem{ID: fact.ID, Name: fact.Name, Type: fact.Type, Year: heroYearOf(fact)}, true
}
func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
policy, err := s.store.HeroPolicy(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero policy unavailable to admin", "error", err)
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
}
items, err := s.store.LibraryItemsByID(ctx, policy.PinnedItemIDs)
if err != nil {
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
}
byID := make(map[string]heroAdminItem, len(items))
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
byID[item.ID] = item
}
}
out := heroAdminPolicy{
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
PrimeSubtitle: policy.PrimeSubtitle,
}
for _, id := range policy.PinnedItemIDs {
if item, ok := byID[id]; ok {
out.PinnedItems = append(out.PinnedItems, item)
}
}
return out
}
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
if err != nil {
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not search the library")
return
}
results := make([]heroAdminItem, 0, len(items))
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
results = append(results, item)
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": results})
}
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
var request struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid hero policy")
return
}
ids := uniqueHeroIDs(request.PinnedItemIDs)
if len(ids) > 4 {
writeError(w, http.StatusBadRequest, "the hero can pin at most four titles")
return
}
items, err := s.store.LibraryItemsByID(r.Context(), ids)
if err != nil {
s.loggerFor(r.Context()).Error("hero title validation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not validate hero titles")
return
}
valid := map[string]bool{}
for _, raw := range items {
if item, ok := adminHeroItem(raw); ok {
valid[item.ID] = true
}
}
for _, id := range ids {
if !valid[id] {
writeError(w, http.StatusBadRequest, "every pinned item must be a playable library film or series")
return
}
}
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle}
if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("hero policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save hero policy")
return
}
s.invalidateAllHomeCaches(r.Context())
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
}
func uniqueHeroIDs(ids []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
func (s *Server) invalidateAllHomeCaches(ctx context.Context) {
users, err := s.store.KnownUsers(ctx)
if err != nil {
s.loggerFor(ctx).Warn("hero cache invalidation could not list users", "error", err)
return
}
for _, user := range users {
if err := s.cache.InvalidateUser(ctx, user.ID); err != nil {
s.loggerFor(ctx).Warn("hero cache invalidation failed", "user", user.ID, "error", err)
}
}
}
+72
View File
@@ -537,6 +537,78 @@ func TestHeroRotationSlotIsLocal(t *testing.T) {
} }
} }
func TestPinnedHeroMoviesLeadAndOrganicSelectionFillsTheGrid(t *testing.T) {
pinned := []heroCandidate{
{ID: "custom-2", Item: json.RawMessage(`{"Id":"custom-2"}`)},
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`)},
}
organic := []heroCandidate{
// A pinned title can also qualify organically; it must still appear only once.
{ID: "custom-1", Item: json.RawMessage(`{"Id":"custom-1"}`), Kind: heroSeasonPremiere},
{ID: "release-1", Item: json.RawMessage(`{"Id":"release-1"}`)},
{ID: "release-2", Item: json.RawMessage(`{"Id":"release-2"}`)},
{ID: "library-1", Item: json.RawMessage(`{"Id":"library-1"}`)},
}
got := mergePinnedHeroCandidates(pinned, organic, organic[1:], 4)
want := []string{"custom-2", "custom-1", "release-1", "release-2"}
if strings.Join(heroIDs(got), ",") != strings.Join(want, ",") {
t.Fatalf("pinned hero order = %v, want %v", heroIDs(got), want)
}
if got[1].Kind != heroSeasonPremiere {
t.Fatalf("pin lost its natural premiere evidence: %+v", got[1])
}
}
func TestPinnedHeroMergeHonoursAnEmptyOrShortGrid(t *testing.T) {
candidate := heroCandidate{ID: "custom", Item: json.RawMessage(`{"Id":"custom"}`)}
if got := mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 0); got != nil {
t.Fatalf("zero-sized grid = %v, want nil", heroIDs(got))
}
if got := heroIDs(mergePinnedHeroCandidates([]heroCandidate{candidate}, nil, nil, 4)); strings.Join(got, ",") != "custom" {
t.Fatalf("short grid = %v, want custom", got)
}
}
func TestPrimeSubtitleChangesOnlyTheLargeFirstCard(t *testing.T) {
candidate := heroCandidate{ReleasedAt: heroDaysAgo(2), Kind: heroMovie}
if got := heroReasonForPosition(candidate, 0, " Family pick tonight ", heroNow, time.UTC); got != "Family pick tonight" {
t.Fatalf("prime subtitle = %q", got)
}
if got := heroReasonForPosition(candidate, 1, "Family pick tonight", heroNow, time.UTC); got == "Family pick tonight" || !strings.Contains(got, "Released") {
t.Fatalf("secondary card lost its natural reason: %q", got)
}
if got := heroReasonForPosition(candidate, 0, "", heroNow, time.UTC); !strings.Contains(got, "Released") {
t.Fatalf("blank override did not fall back to the natural reason: %q", got)
}
}
func TestPinnedSeriesUsesTheSameNaturalPresentationAsOrganicTitles(t *testing.T) {
candidate := heroCandidate{Kind: heroSeries, ReleasedAt: heroDaysAgo(3)}
if got := heroLabel(candidate, heroNow); got != heroLabelNewRelease {
t.Fatalf("recent series label = %q, want %q", got, heroLabelNewRelease)
}
if got := heroReason(candidate, heroNow, time.UTC); !strings.Contains(got, "new series premiered") {
t.Fatalf("recent series reason = %q", got)
}
candidate.ReleasedAt = heroDaysAgo(100)
candidate.Rated, candidate.Rating = true, 0.9
if got := heroLabel(candidate, heroNow); got != heroLabelAcclaimed {
t.Fatalf("older acclaimed series label = %q, want %q", got, heroLabelAcclaimed)
}
}
func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
for _, itemType := range []string{"Movie", "Series"} {
if item, ok := adminHeroItem(heroItem("id", "Title", itemType)); !ok || item.Type != itemType {
t.Fatalf("%s was not accepted: %+v, %t", itemType, item, ok)
}
}
if _, ok := adminHeroItem(heroItem("episode", "Episode", "Episode")); ok {
t.Fatal("episode was accepted as a pinnable hero title")
}
}
func heroIDs(candidates []heroCandidate) []string { func heroIDs(candidates []heroCandidate) []string {
ids := make([]string, 0, len(candidates)) ids := make([]string, 0, len(candidates))
for _, candidate := range candidates { for _, candidate := range candidates {
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
// daily. History stores changes rather than identical daily snapshots: it still records
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
if s.sonarr == nil || interval <= 0 {
return
}
scan := func() {
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
}
}
scan()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
scan()
}
}
}
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
series, err := s.sonarr.Series(ctx)
if err != nil {
return fmt.Errorf("read Sonarr series: %w", err)
}
now := time.Now().UTC()
observations := make([]store.SonarrSeriesStatus, 0, len(series))
for _, item := range series {
key := sonarrSeriesStatusKey(item)
if key == "" || strings.TrimSpace(item.Status) == "" {
continue
}
observations = append(observations, store.SonarrSeriesStatus{
SeriesKey: key, SonarrSeriesID: item.ID, TVDBID: item.TVDBID,
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
})
}
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
if err != nil {
return err
}
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
for _, change := range changes {
if sonarrBecameCancelled(change.PreviousStatus, change.Current.Status) {
cancellations = append(cancellations, change)
}
}
if len(cancellations) == 0 {
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
return nil
}
users, err := s.store.KnownUsers(ctx)
if err != nil {
return err
}
preferences := map[string]store.NotificationPreferences{}
preferenceErrors := map[string]bool{}
notifications := 0
for _, change := range cancellations {
for _, user := range users {
prefs, ok := preferences[user.ID]
if !ok && !preferenceErrors[user.ID] {
prefs, err = s.store.NotificationPreferences(ctx, user.ID)
if err != nil {
preferenceErrors[user.ID] = true
s.log.Warn("notification preferences unavailable during Sonarr lifecycle scan",
"user", user.ID, "error", err)
continue
}
preferences[user.ID] = prefs
}
if preferenceErrors[user.ID] || !prefs.Enabled {
continue
}
eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
message := change.Current.Title + " is now listed as cancelled in Sonarr."
if err := s.store.UpsertNotification(
ctx, user.ID, sourceKey, "show-cancelled", "",
"Show cancelled", message, &eventAt,
); err != nil {
s.log.Warn("Sonarr cancellation notification failed",
"user", user.ID, "show", change.Current.Title, "error", err)
continue
}
notifications++
}
}
s.log.Info("Sonarr lifecycle scan complete",
"series", len(observations), "changes", len(changes),
"cancelled", len(cancellations), "notifications", notifications)
return nil
}
func sonarrSeriesStatusKey(series sonarr.Series) string {
if series.TVDBID > 0 {
return "tvdb:" + strconv.Itoa(series.TVDBID)
}
if series.ID > 0 {
return "sonarr:" + strconv.Itoa(series.ID)
}
return ""
}
func sonarrBecameCancelled(previous, current string) bool {
previous = strings.ToLower(strings.TrimSpace(previous))
current = strings.ToLower(strings.TrimSpace(current))
active := previous == "continuing" || previous == "upcoming"
cancelled := current == "ended" || current == "deleted" ||
current == "cancelled" || current == "canceled"
return active && cancelled
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestSonarrSeriesStatusKeyPrefersTVDBIdentity(t *testing.T) {
series := sonarr.Series{ID: 42, TVDBID: 1234}
if got := sonarrSeriesStatusKey(series); got != "tvdb:1234" {
t.Fatalf("status key = %q, want tvdb:1234", got)
}
series.TVDBID = 0
if got := sonarrSeriesStatusKey(series); got != "sonarr:42" {
t.Fatalf("fallback status key = %q, want sonarr:42", got)
}
}
func TestSonarrCancellationRequiresAnActiveHistory(t *testing.T) {
for _, transition := range [][2]string{
{"continuing", "ended"},
{"upcoming", "deleted"},
{"Continuing", "cancelled"},
} {
if !sonarrBecameCancelled(transition[0], transition[1]) {
t.Errorf("%q -> %q should be a cancellation", transition[0], transition[1])
}
}
for _, transition := range [][2]string{
{"", "ended"}, // first scan is a baseline, not news
{"ended", "ended"}, // an unchanged cancelled show is not announced daily
{"ended", "continuing"},
{"continuing", "continuing"},
} {
if sonarrBecameCancelled(transition[0], transition[1]) {
t.Errorf("%q -> %q should not be a cancellation", transition[0], transition[1])
}
}
}
+96
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -31,6 +32,24 @@ type UserNotification struct {
ReadAt *time.Time `json:"readAt,omitempty"` ReadAt *time.Time `json:"readAt,omitempty"`
} }
// SonarrSeriesStatus is one daily observation. SeriesKey prefers Sonarr's stable TVDB id;
// the local Sonarr id is retained for diagnosis and as a fallback when TVDB has no answer.
type SonarrSeriesStatus struct {
SeriesKey string
SonarrSeriesID int
TVDBID int
Title string
Year int
Status string
ObservedAt time.Time
}
type SonarrSeriesStatusChange struct {
HistoryID int64
PreviousStatus string
Current SonarrSeriesStatus
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error { func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, ` _, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag) INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
@@ -89,6 +108,83 @@ func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error
return shows, rows.Err() return shows, rows.Err()
} }
// RecordSonarrSeriesStatuses appends only first sightings and changes. A first sighting is
// the baseline and is deliberately absent from the returned changes, so enabling the
// scanner cannot announce every series that was already cancelled before it existed.
func (s *Store) RecordSonarrSeriesStatuses(
ctx context.Context, observations []SonarrSeriesStatus,
) ([]SonarrSeriesStatusChange, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("store: begin Sonarr status history: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT DISTINCT ON (series_key) series_key, status
FROM sonarr_series_status_history
ORDER BY series_key, observed_at DESC, id DESC`)
if err != nil {
return nil, fmt.Errorf("store: read Sonarr status history: %w", err)
}
previous := map[string]string{}
for rows.Next() {
var key, status string
if err := rows.Scan(&key, &status); err != nil {
rows.Close()
return nil, fmt.Errorf("store: scan Sonarr status history: %w", err)
}
previous[key] = status
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("store: iterate Sonarr status history: %w", err)
}
rows.Close()
changes := []SonarrSeriesStatusChange{}
seen := map[string]bool{}
for _, observation := range observations {
observation.SeriesKey = strings.TrimSpace(observation.SeriesKey)
observation.Title = strings.TrimSpace(observation.Title)
observation.Status = strings.ToLower(strings.TrimSpace(observation.Status))
if observation.SeriesKey == "" || observation.Title == "" || observation.Status == "" ||
seen[observation.SeriesKey] {
continue
}
seen[observation.SeriesKey] = true
prior, known := previous[observation.SeriesKey]
if known && strings.EqualFold(prior, observation.Status) {
continue
}
if observation.ObservedAt.IsZero() {
observation.ObservedAt = time.Now()
}
var historyID int64
err := tx.QueryRow(ctx, `
INSERT INTO sonarr_series_status_history
(series_key, sonarr_series_id, tvdb_id, title, year, status, observed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
observation.SeriesKey, observation.SonarrSeriesID, observation.TVDBID,
observation.Title, observation.Year, observation.Status, observation.ObservedAt,
).Scan(&historyID)
if err != nil {
return nil, fmt.Errorf("store: append Sonarr status history: %w", err)
}
if known {
changes = append(changes, SonarrSeriesStatusChange{
HistoryID: historyID, PreviousStatus: prior, Current: observation,
})
}
previous[observation.SeriesKey] = observation.Status
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("store: commit Sonarr status history: %w", err)
}
return changes, nil
}
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) { func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7} prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
+17
View File
@@ -239,6 +239,23 @@ CREATE TABLE IF NOT EXISTS user_notifications (
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
ON user_notifications (emby_user_id, created_at DESC); ON user_notifications (emby_user_id, created_at DESC);
-- A change-only history of Sonarr's lifecycle answer for every series. The first reading
-- is a baseline; later rows mean Sonarr changed its answer, which lets the daily scanner
-- distinguish a show that was already over from one that has just been cancelled.
CREATE TABLE IF NOT EXISTS sonarr_series_status_history (
id BIGSERIAL PRIMARY KEY,
series_key TEXT NOT NULL,
sonarr_series_id INT NOT NULL DEFAULT 0,
tvdb_id INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
year INT NOT NULL DEFAULT 0,
status TEXT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sonarr_series_status_history_series_time_idx
ON sonarr_series_status_history (series_key, observed_at DESC, id DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or -- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary. -- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking. -- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
+71 -1
View File
@@ -18,14 +18,84 @@ const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr. // RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy" const RequestPolicyKey = "request_policy"
// PlaybackPolicyKey controls presentation behavior that should be adjustable without // PlaybackPolicyKey controls presentation behaviour that should be adjustable without
// shipping a new TV build. // shipping a new TV build.
const PlaybackPolicyKey = "playback_policy" const PlaybackPolicyKey = "playback_policy"
// HeroPolicyKey stores the operator's explicit choices for the launcher hero.
const HeroPolicyKey = "hero_policy"
// MDBListSettingsKey stores the optional movie-ratings integration. The API key stays // MDBListSettingsKey stores the optional movie-ratings integration. The API key stays
// in this server-owned document and is never included in client or admin status payloads. // in this server-owned document and is never included in client or admin status payloads.
const MDBListSettingsKey = "mdblist_settings" const MDBListSettingsKey = "mdblist_settings"
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
// remain library data, so a metadata correction appears without rewriting operator policy.
type HeroPolicy struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
PrimeSubtitle string `json:"primeSubtitle"`
UpdatedAt time.Time `json:"updatedAt"`
}
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
if len(policy.PinnedItemIDs) == 0 && len(policy.LegacyPinnedMovieIDs) > 0 {
policy.PinnedItemIDs = policy.LegacyPinnedMovieIDs
}
seen := map[string]bool{}
ids := make([]string, 0, min(len(policy.PinnedItemIDs), 4))
for _, id := range policy.PinnedItemIDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] || len(ids) == 4 {
continue
}
seen[id] = true
ids = append(ids, id)
}
policy.PinnedItemIDs = ids
policy.LegacyPinnedMovieIDs = nil
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
runes := []rune(policy.PrimeSubtitle)
if len(runes) > 160 {
policy.PrimeSubtitle = string(runes[:160])
}
return policy
}
func (s *Store) HeroPolicy(ctx context.Context) (HeroPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, HeroPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return HeroPolicy{PinnedItemIDs: []string{}}, nil
}
if err != nil {
return HeroPolicy{}, fmt.Errorf("store: read hero policy: %w", err)
}
var policy HeroPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return HeroPolicy{}, fmt.Errorf("store: decode hero policy: %w", err)
}
return normalizeHeroPolicy(policy), nil
}
func (s *Store) SetHeroPolicy(ctx context.Context, policy HeroPolicy) error {
policy = normalizeHeroPolicy(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
HeroPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write hero policy: %w", err)
}
return nil
}
var defaultMDBListSources = []string{ var defaultMDBListSources = []string{
"imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert", "imdb", "tomatoes", "audience", "metacritic", "letterboxd", "rogerebert",
"tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average", "tmdb", "trakt", "mal", "anilist", "anidb", "kitsu", "score", "score_average",
+26 -1
View File
@@ -25,6 +25,31 @@ func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
} }
} }
func TestHeroPolicyKeepsFourUniqueTrimmedItemIDsInOrder(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{PinnedItemIDs: []string{
" movie-2 ", "movie-1", "movie-2", "", "movie-3", "movie-4", "movie-5",
}, PrimeSubtitle: " Tonights pick "})
want := []string{"movie-2", "movie-1", "movie-3", "movie-4"}
if len(got.PinnedItemIDs) != len(want) {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
for index := range want {
if got.PinnedItemIDs[index] != want[index] {
t.Fatalf("pinned ids = %v, want %v", got.PinnedItemIDs, want)
}
}
if got.PrimeSubtitle != "Tonights pick" {
t.Fatalf("prime subtitle = %q", got.PrimeSubtitle)
}
}
func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
got := normalizeHeroPolicy(HeroPolicy{LegacyPinnedMovieIDs: []string{"movie-1"}})
if len(got.PinnedItemIDs) != 1 || got.PinnedItemIDs[0] != "movie-1" {
t.Fatalf("legacy pinned ids = %v", got.PinnedItemIDs)
}
}
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) { func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
defaults := DefaultMDBListSettings() defaults := DefaultMDBListSettings()
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 { if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
@@ -35,7 +60,7 @@ func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
}) })
if got.APIKey != "secret" || len(got.Sources) != 2 || if got.APIKey != "secret" || len(got.Sources) != 2 ||
got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" { got.Sources[0] != "imdb" || got.Sources[1] != "letterboxd" {
t.Fatalf("normalized MDBList settings = %+v", got) t.Fatalf("normalised MDBList settings = %+v", got)
} }
} }