0.2.52
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user