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
+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
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.51"
val defaultVersionName = "0.2.53"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -403,6 +403,10 @@ data class Settings(
* not seen that build's update notice.
*/
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
* 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 ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
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 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
* build and therefore has nothing to announce.
*/
suspend fun markWhatsNewSeen(version: String) {
suspend fun markWhatsNewSeen(version: String, updateAlertAt: String? = null) {
val trimmed = version.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
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(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
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],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
profiles = profiles,
@@ -115,6 +115,53 @@ fun normalizeSubtitleLanguage(raw: String?): String {
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. */
data class SubtitleCandidate(
val id: String,
@@ -173,7 +173,7 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
// has one — see [TvNavigationRail]'s calendarEnabled.
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
FAVORITES("Favourites", Icons.Default.Favorite),
PROFILES("Switch user", Icons.Default.Person),
PROFILES("User", Icons.Default.Person),
SETTINGS("Settings", Icons.Default.Settings),
}
@@ -361,6 +361,11 @@ fun TvNavigationRail(
destinations.forEach { destination ->
ExpandableNavigationItem(
destination = destination,
label = if (destination == BrowseDestination.PROFILES) {
profileDestinationLabel(activeUsername)
} else {
destination.label
},
selected = destination == selected,
expanded = expanded,
modifier = if (destination == focusDestination) {
@@ -370,7 +375,7 @@ fun TvNavigationRail(
},
onFocused = {},
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.
badge = if (destination == BrowseDestination.PROFILES) {
alertBadgeLabel(alertCount)
@@ -526,12 +531,12 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)),
)
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
// "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.
UserSwitcherAction(
label = "My Alerts",
label = "Notifications",
icon = Icons.Default.Notifications,
badge = alertBadgeLabel(alertCount),
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
@Composable
@@ -678,6 +683,7 @@ private val AlertBadgeRed = Color(0xFFE04747)
@Composable
fun ExpandableNavigationItem(
destination: BrowseDestination,
label: String = destination.label,
selected: Boolean,
expanded: Boolean,
onFocused: () -> Unit,
@@ -719,8 +725,7 @@ fun ExpandableNavigationItem(
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics {
contentDescription = badge?.let { "${destination.label}, $it waiting" }
?: destination.label
contentDescription = badge?.let { "$label, $it waiting" } ?: label
}
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -773,7 +778,7 @@ fun ExpandableNavigationItem(
}
if (expanded) {
Text(
destination.label,
label,
color = foreground.value,
fontSize = 15.sp,
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
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current
@@ -2143,6 +2152,9 @@ private fun MediaCard(
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
if (focused) {
MembyArtworkPlayCue(Modifier.align(Alignment.Center))
}
}
Text(
item.name,
@@ -2,7 +2,6 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,7 +26,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
@@ -345,6 +339,12 @@ private fun FeaturedMovieCard(
previewArtwork: ImageBitmap?,
) {
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(
onFocused = onFocused,
onClick = onClick,
@@ -403,16 +403,26 @@ private fun FeaturedMovieCard(
// 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:
// they have no fact line, and there the label is the only reason given.
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
onTextLayout = { titleLines = it.lineCount },
)
if (useTextTitle) {
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 32.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
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))
HeroFactLine(item)
// The reason takes the synopsis's place rather than adding a line to
@@ -540,24 +550,11 @@ private fun MiniMovieCard(
}
}
if (focused) {
Box(
MembyArtworkPlayCue(
modifier = Modifier
.align(Alignment.CenterEnd)
.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),
)
}
.padding(end = 16.dp),
)
}
}
}
@@ -179,6 +179,26 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Date
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.
@@ -498,7 +518,10 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
// Persist the receipt before producing the external effect. If Android
// recreates the activity immediately after the toast, the new composition
// 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()
Toast.makeText(
context,
@@ -1938,6 +1961,13 @@ private fun HomeScreen(
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
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) }
// 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
@@ -2396,7 +2426,7 @@ private fun HomeScreen(
}
}
},
alertCount = notificationState.notifications.size,
alertCount = displayedNotifications.size,
activeUsername = settings.username.orEmpty(),
calendarEnabled = tvCalendarEnabled,
)
@@ -2408,7 +2438,7 @@ private fun HomeScreen(
) {
val maintenanceMessage = liveMaintenance?.message ?: homeStatus.maintenanceMessage
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.
MaintenanceScreen(
message = maintenanceMessage,
@@ -3037,7 +3067,7 @@ private fun HomeScreen(
navigationExpanded = false
showProfiles = true
},
alertCount = notificationState.notifications.size,
alertCount = displayedNotifications.size,
onOpenAlerts = {
homeViewModel.trackJourney(
category = "notifications", action = "open", screen = journeyScreen,
@@ -3086,7 +3116,7 @@ private fun HomeScreen(
}
BackHandler(onBack = closeSettings)
// 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
// only reserves its collapsed footprint and takes the same expand shift the
// content area does, so an expanded rail slides Settings aside rather than
@@ -3329,7 +3359,7 @@ private fun HomeScreen(
}
BackHandler(onBack = closeAlerts)
MyAlertsPage(
notifications = notificationState.notifications,
notifications = displayedNotifications,
preferences = notificationState.preferences,
loading = notificationsLoading,
errorMessage = notificationsError,
@@ -3373,7 +3403,11 @@ private fun HomeScreen(
// 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
// 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
notificationState = notificationState.copy(
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
// page also re-aims where focus lands afterwards. A failure puts the row
// back where it was rather than quietly losing somebody's alert.
onDismiss = { notification ->
if (notificationsMutationBusy) return@MyAlertsPage
onDismiss = onDismiss@{ notification ->
if (notificationsMutationBusy) return@onDismiss
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.dismissUpdateAlert() }
return@onDismiss
}
notificationsMutationBusy = true
val previous = notificationState
notificationState = notificationState.copy(
@@ -3428,8 +3466,10 @@ private fun HomeScreen(
notificationsMutationBusy = true
val previous = notificationState
val pending = notificationState.notifications.map(UserNotification::id)
val dismissLocalUpdate = settings.updateAlertVersion != null
notificationState = notificationState.copy(notifications = emptyList())
scope.launch {
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
val failed = pending.filter { id ->
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.
*
* 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
* unavailable. Everything here is cheap to draw a handful of animated floats and plain
* 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.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
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
private fun PrimaryActionSurface(
label: String,
@@ -3,7 +3,7 @@ package com.ponzischeme89.memby.ui
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
* deterministic.
*/
@@ -1,7 +1,7 @@
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.
*
* 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 {
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 -> {
val alerts = if (total == 1) "1 alert" else "$total alerts"
"$alerts · $unread new"
val notifications = if (total == 1) "1 notification" else "$total notifications"
"$notifications · $unread new"
}
}
@@ -60,7 +60,7 @@ import com.ponzischeme89.memby.ui.theme.MembySurface
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.
*
* 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,
) {
MembyChoiceChip(
label = if (preferences.enabled) "Alerts on" else "Alerts off",
label = if (preferences.enabled) "Notifications on" else "Notifications off",
selected = preferences.enabled,
onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester),
@@ -177,7 +177,7 @@ fun MyAlertsPage(
Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (loading && !hasAlerts) {
AlertsNotice("Loading alerts…")
AlertsNotice("Loading notifications…")
} else if (errorMessage != null && !hasAlerts) {
AlertsNotice(errorMessage)
} else if (!hasAlerts) {
@@ -232,7 +232,7 @@ private fun AlertsHeader(total: Int, unread: Int) {
modifier = Modifier.padding(start = 16.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)
}
}
@@ -248,9 +248,9 @@ private fun AlertsEmptyState(enabled: Boolean) {
Spacer(Modifier.height(8.dp))
Text(
if (enabled) {
"Alerts about the shows you follow will show up here."
"Notifications about the shows you follow will show up here."
} else {
"Alerts are switched off, so nothing new will arrive here."
"Notifications are switched off, so nothing new will arrive here."
},
color = MembyQuietText,
fontSize = 14.sp,
@@ -356,5 +356,6 @@ private fun AlertRow(
private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", 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
}
@@ -76,12 +76,16 @@ import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.resolveCast
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.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewaySeasonFinale
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.EmbyOutageBanner
import com.ponzischeme89.memby.ui.ServiceAlertBanner
@@ -104,6 +108,13 @@ import java.util.Locale
import kotlin.math.abs
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
* 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
// 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 {
if (audio) add("Audio")
add("Subtitles & appearance")
add("Picture size · ${selectedPictureMode().shortLabel}")
add(passthroughOption)
add(pictureOption)
}
AlertDialog.Builder(this)
.setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options")
@@ -3205,12 +3220,67 @@ class PlayerActivity : ComponentActivity() {
when (options[which]) {
"Audio" -> showTrackPicker(C.TRACK_TYPE_AUDIO)
"Subtitles & appearance" -> showSubtitleOverlay()
else -> showPictureModePicker()
passthroughOption -> showAudioPassthroughPicker()
pictureOption -> showPictureModePicker()
}
}
.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() {
val selected = selectedPictureMode()
AlertDialog.Builder(this)
@@ -3483,7 +3553,7 @@ class PlayerActivity : ComponentActivity() {
discoveredTracks += TrackChoice(
group.mediaTrackGroup,
index,
trackLabel(group.mediaTrackGroup, index),
trackLabel(group.mediaTrackGroup, index, showSubtitleFlag = true),
)
}
}
@@ -3609,7 +3679,10 @@ class PlayerActivity : ComponentActivity() {
subtitleCandidates.forEach { candidate ->
add(
SubtitleMenuEntry(
label = candidate.label.ifBlank { candidate.languageLabel },
label = subtitleLabelWithFlag(
candidate.language,
candidate.label.ifBlank { candidate.languageLabel },
),
selected = false,
enabled = !subtitleRequestInFlight,
),
@@ -3817,7 +3890,8 @@ class PlayerActivity : ComponentActivity() {
if (subtitle.isDefault) add("Default")
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) {
@@ -3933,7 +4007,7 @@ class PlayerActivity : ComponentActivity() {
}
@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 rawLabel = format.label?.trim()?.takeIf { it.isNotBlank() }
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.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) {
@@ -7,6 +7,7 @@ import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
@@ -39,7 +40,7 @@ data class SubtitleDownloadState(
val entries: List<SubtitleMenuEntry> = emptyList(),
/**
* 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
* 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.
@@ -99,6 +100,9 @@ fun bindSubtitleMenu(
tracks.forEachIndexed { index, entry ->
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 ->
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
}
@@ -128,6 +132,16 @@ private fun bindDownloadSection(
downloads.entries.forEachIndexed { index, entry ->
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()
layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 40 else 46),
dp(if (chip) 36 else 40),
).apply {
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))
}
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
maxLines = 1
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)
// 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.
@@ -128,6 +128,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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)
@@ -1151,10 +1155,17 @@ internal fun deviceDescription(device: GatewayDevice): String = buildList {
if (device.current) add("This TV")
device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
device.lastSeenAt.takeIf(String::isNotBlank)?.let {
add("Last active ${it.replace('T', ' ').substringBefore('.').removeSuffix("Z")}")
add("Last active ${formatDeviceLastSeen(it)}")
}
}.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
private fun DeviceManagementRow(
device: GatewayDevice,
@@ -1994,7 +2005,7 @@ private fun ReleaseHistoryRow(
}
Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let {
Text(it, color = TextQuiet, fontSize = 12.sp)
Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
@@ -13,6 +13,22 @@ internal data class ReleaseNote(
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*(.+))?$""")
/**
@@ -6,7 +6,7 @@
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
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.
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. -->
@@ -21,7 +21,7 @@
<LinearLayout
android:id="@+id/player_subtitle_menu"
android:layout_width="344dp"
android:layout_width="316dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginTop="96dp"
@@ -30,9 +30,9 @@
android:background="@drawable/player_menu_background"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="14dp"
android:paddingTop="10dp"
android:paddingEnd="10dp"
android:paddingBottom="12dp">
android:paddingBottom="10dp">
<LinearLayout
android:id="@+id/player_subtitle_main_section"
@@ -67,10 +67,10 @@
android:visibility="gone" />
<ScrollView
android:id="@+id/player_subtitle_tracks_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
@@ -86,7 +86,7 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:layout_marginTop="7dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
@@ -94,7 +94,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#7AFFFFFF"
@@ -105,7 +105,7 @@
android:id="@+id/player_subtitle_sizes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginTop="6dp"
android:orientation="horizontal" />
</LinearLayout>
@@ -115,7 +115,6 @@
android:id="@+id/player_subtitle_download_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone">
@@ -127,7 +126,7 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
@@ -135,7 +134,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginTop="8dp"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_download"
android:textColor="#7AFFFFFF"
@@ -154,10 +153,10 @@
android:visibility="gone" />
<ScrollView
android:id="@+id/player_subtitle_downloads_scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
@@ -32,6 +32,14 @@ class SubtitlePreferenceTest {
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
fun `subtitles turned off means nothing is selected`() {
val tracks = listOf(track("1", "eng", default = true))
@@ -16,4 +16,10 @@ class ProfileInitialsTest {
assertEquals("AL", profileInitials("alwyn"))
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`() {
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(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
@@ -31,11 +31,11 @@ class AlertsFormatTest {
}
@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("1 alert", alertsSummary(total = 1, unread = 0))
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0))
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1))
assertEquals("1 notification", alertsSummary(total = 1, unread = 0))
assertEquals("4 notifications", alertsSummary(total = 4, unread = 0))
assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1))
}
}
@@ -16,7 +16,7 @@ import org.robolectric.annotation.Config
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.
*
* ```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",
tracks = listOf(
entry("Off"),
entry("English", selected = true),
entry("English · SDH"),
entry("Italian · Forced"),
entry("🇬🇧 English", selected = true),
entry("🇬🇧 English · SDH"),
entry("🇮🇹 Italian · Forced"),
),
focused = 1,
)
@@ -54,7 +54,7 @@ class SubtitleMenuScreenshotTest {
fun `subtitles off`() {
capture(
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,
)
}
@@ -64,7 +64,7 @@ class SubtitleMenuScreenshotTest {
fun `text size focused`() {
capture(
name = "subtitles-menu-text-size",
tracks = listOf(entry("Off"), entry("English", selected = true)),
tracks = listOf(entry("Off"), entry("🇬🇧 English", selected = true)),
focusedSize = 2,
)
}
@@ -79,14 +79,14 @@ class SubtitleMenuScreenshotTest {
name = "subtitles-menu-long-list",
tracks = listOf(
entry("Off"),
entry("English", selected = true),
entry("English · SDH"),
entry("Français"),
entry("Deutsch · Forced"),
entry("Italiano"),
entry("Español · Default"),
entry("Português (Brasil)"),
entry("Nederlands · SDH · Burned in"),
entry("🇬🇧 English", selected = true),
entry("🇬🇧 English · SDH"),
entry("🇫🇷 Français"),
entry("🇩🇪 Deutsch · Forced"),
entry("🇮🇹 Italiano"),
entry("🇪🇸 Español · Default"),
entry("🇧🇷 Português (Brasil)"),
entry("🇳🇱 Nederlands · SDH · Burned in"),
),
focused = 1,
)
@@ -101,7 +101,7 @@ class SubtitleMenuScreenshotTest {
fun `download offered with nothing suitable in the list`() {
capture(
name = "subtitles-menu-download-offered",
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
downloads = SubtitleDownloadState(
available = true,
entries = listOf(entry("Search for subtitles…", prominent = true)),
@@ -134,16 +134,16 @@ class SubtitleMenuScreenshotTest {
fun `download results`() {
capture(
name = "subtitles-menu-download-results",
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
tracks = listOf(entry("Off", selected = true), entry("🇩🇪 Deutsch")),
downloads = SubtitleDownloadState(
available = true,
expanded = true,
entries = listOf(
entry("Search again", prominent = true),
entry("English · 98% match"),
entry("English · Hearing impaired · 96% match"),
entry("English · Forced · 91% match"),
entry("Italian · 84% match"),
entry("🇬🇧 English · 98% match"),
entry("🇬🇧 English · Hearing impaired · 96% match"),
entry("🇬🇧 English · Forced · 91% match"),
entry("🇮🇹 Italian · 84% match"),
),
),
focusedDownload = 1,
@@ -3,6 +3,7 @@ package com.ponzischeme89.memby.ui.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
class VersionHistoryTest {
@@ -69,4 +70,19 @@ class VersionHistoryTest {
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
import com.ponzischeme89.memby.ui.MEMBY_UPDATE_NOTIFICATION_ID
import com.ponzischeme89.memby.ui.membyUpdateNotification
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
class WhatsNewTest {
@@ -49,4 +53,14 @@ class WhatsNewTest {
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))
}
}