This commit is contained in:
ponzischeme89
2026-08-22 21:47:54 +12:00
parent d2f7f84cbf
commit 9a1f44da46
27 changed files with 452 additions and 110 deletions
@@ -39,7 +39,7 @@ data class EmbyOutage(
/**
* One informational banner: a show aired, a film was added, the library finished
* refreshing, the server stopped answering. It is never actionable and never focusable —
* it slides in, says its piece and goes, over the launcher or over playback alike.
* it slides in, says its piece and goes wherever the server's display policy permits.
*
* [label] is the eyebrow above the title and comes from the server, so a kind of news
* this build has never heard of still reads correctly; a server that sends none gets the
@@ -83,6 +83,9 @@ class MaintenanceMonitor(
private val _alert = MutableStateFlow<ServiceAlert?>(null)
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
private val _notificationDisplay = MutableStateFlow(NotificationDisplayPolicy.HOME_ONLY)
val notificationDisplay: StateFlow<NotificationDisplayPolicy> = _notificationDisplay.asStateFlow()
private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)
/** Null whenever Emby is answering, or when nothing is watching it. */
@@ -296,6 +299,7 @@ class MaintenanceMonitor(
_viewersEnabled.value = false
setRequestsAllowed(false)
_gatewayVersion.value = ""
_notificationDisplay.value = NotificationDisplayPolicy.HOME_ONLY
dismissAlert()
return@collectLatest
}
@@ -344,6 +348,8 @@ class MaintenanceMonitor(
_viewersEnabled.value = status.features[VIEWERS_FEATURE] == true
setRequestsAllowed(status.requests.allowed)
_gatewayVersion.value = status.gatewayVersion
_notificationDisplay.value =
NotificationDisplayPolicy.fromWire(status.notificationDisplay)
// Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll.
@@ -387,6 +393,7 @@ class MaintenanceMonitor(
_viewersEnabled.value = false
setRequestsAllowed(false)
_gatewayVersion.value = ""
_notificationDisplay.value = NotificationDisplayPolicy.HOME_ONLY
dismissAlert()
return@collectLatest
}
@@ -0,0 +1,37 @@
package com.ponzischeme89.memby.data
/** Household-wide notification placement chosen by the server administrator. */
enum class NotificationDisplayPolicy(val wireValue: String) {
EVERYWHERE("everywhere"),
HOME_ONLY("home_only"),
OFF("off"),
;
companion object {
/** Missing and unknown values fail safe: browsing is allowed, playback is not. */
fun fromWire(value: String?): NotificationDisplayPolicy =
entries.firstOrNull { it.wireValue == value?.trim()?.lowercase() } ?: HOME_ONLY
}
}
/** The two display contexts that matter to the global interruption policy. */
enum class NotificationSurface {
BROWSING,
PLAYBACK,
}
/**
* The single eligibility rule for server notifications.
*
* Callers identify their surface; this function owns what each server policy means. New
* notification renderers should use this rather than interpreting the wire value or
* making their own playback decision.
*/
fun notificationEligible(
policy: NotificationDisplayPolicy,
surface: NotificationSurface,
): Boolean = when (policy) {
NotificationDisplayPolicy.EVERYWHERE -> true
NotificationDisplayPolicy.HOME_ONLY -> surface == NotificationSurface.BROWSING
NotificationDisplayPolicy.OFF -> false
}
@@ -213,6 +213,8 @@ data class GatewayServiceStatus(
val maintenance: Boolean = false,
val message: String = "",
val alerts: List<GatewayAlert> = emptyList(),
/** Server-owned placement policy. Missing or unreadable means home only. */
val notificationDisplay: String = "home_only",
val compatible: Boolean = true,
val compatibilityMessage: String = "",
val clientVersion: String = "",
@@ -2,10 +2,10 @@ 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
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -28,12 +28,17 @@ 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.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CornerRadius
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Offset
import androidx.compose.ui.graphics.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@@ -62,6 +67,8 @@ import kotlinx.coroutines.delay
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
private val HeroFocusRingWidth = 2.dp
/**
* The current local day, re-read when the clock passes midnight.
*
@@ -385,17 +392,11 @@ private fun FeaturedMovieCard(
// Not "Featured movie": a series premiere can lead, and a screen reader announcing
// one as a movie is worse than one announcing it by the caption it is wearing.
contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
modifier = modifier,
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyPanelCorner),
),
HeroFocusFrame(
focused = focused,
modifier = Modifier.fillMaxSize(),
) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(
@@ -547,17 +548,11 @@ private fun MiniMovieCard(
onFocused = onFocused,
onClick = onClick,
contentDescription = "${pick.label} movie, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
modifier = modifier,
) { focused ->
Box(
Modifier
.fillMaxSize()
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyPanelCorner),
),
HeroFocusFrame(
focused = focused,
modifier = Modifier.fillMaxSize(),
) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
@@ -604,6 +599,47 @@ private fun MiniMovieCard(
}
}
/**
* The common artwork boundary for both hero sizes.
*
* The artwork is clipped to the hero shape, while the focus stroke is drawn immediately
* outside that boundary. Keeping those as separate modifier layers means the ring neither
* clips itself nor consumes any of the artwork, and its physical width stays the same for
* the featured card and every mini card.
*/
@Composable
private fun HeroFocusFrame(
focused: Boolean,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
val shape = RoundedCornerShape(MembyPanelCorner)
Box(
modifier = modifier
.drawWithCache {
val strokeWidth = HeroFocusRingWidth.toPx()
val halfStroke = strokeWidth / 2f
val ringBounds = Size(size.width + strokeWidth, size.height + strokeWidth)
val ringCorner = CornerRadius(MembyPanelCorner.toPx() + halfStroke)
onDrawWithContent {
drawContent()
if (focused) {
drawRoundRect(
color = Color.White,
topLeft = Offset(-halfStroke, -halfStroke),
size = ringBounds,
cornerRadius = ringCorner,
style = Stroke(strokeWidth),
)
}
}
}
.clip(shape)
.background(MembySurfaceRaised),
content = content,
)
}
@Composable
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
if (previewArtwork != null) {
@@ -68,6 +68,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.NotificationSurface
import com.ponzischeme89.memby.data.StartupPosterSnapshotCache
import com.ponzischeme89.memby.data.StartupPosterSource
import com.ponzischeme89.memby.data.friendlyEmbyError
@@ -2765,6 +2766,7 @@ internal fun HomeScreen(
// News about the library, not about the app: it sits above the rows but is
// suppressed whenever something more important owns the screen.
ServiceAlertBanner(
surface = NotificationSurface.BROWSING,
// The alert itself is collected inside the banner, so an arriving one does
// not recompose this whole function. Only the suppression conditions — both
// already read here for other reasons — cross the boundary.
@@ -50,7 +50,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.NotificationSurface
import com.ponzischeme89.memby.data.ServiceAlert
import com.ponzischeme89.memby.data.notificationEligible
import kotlin.math.ceil
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -61,8 +63,8 @@ private val AlertBody = Color(0xFFC3CBD2)
/**
* Height of the bar itself, before the rule and fade that blend it into the screen.
*
* Kept to a strip rather than a panel because this now appears over playback as well as
* over the launcher: whatever it says, it is covering somebody's film while it says it.
* Kept to a strip rather than a panel because the server may permit it over playback as
* well as over the launcher.
*/
private val BannerHeight = 48.dp
@@ -83,12 +85,19 @@ private val SafeAreaHorizontal = 48.dp
* allowed to own the whole display.
*/
@Composable
fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
fun ServiceAlertBanner(
surface: NotificationSurface,
suppressed: Boolean,
modifier: Modifier = Modifier,
) {
// The alert is collected *here* rather than passed down from the home composable.
// Read one scope up and every arriving alert would recompose the entire launcher
// body; read here and it recomposes a bar that is usually not even on screen.
val current by ServiceLocator.maintenance.alert.collectAsStateWithLifecycle()
val alert = current?.takeUnless { suppressed }
val policy by ServiceLocator.maintenance.notificationDisplay.collectAsStateWithLifecycle()
val alert = current?.takeIf {
!suppressed && notificationEligible(policy, surface)
}
// Tell the monitor the moment this is really on screen. Until it hears that, the
// alert is only offered — it starts no dismissal timer and persists nothing — so an
@@ -131,6 +131,7 @@ fun LandscapeCard(
modifier: Modifier = Modifier,
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
showFavouriteIndicator: Boolean = true,
) {
val cardsAcross = when (density) {
"compact" -> 5
@@ -138,12 +139,13 @@ fun LandscapeCard(
else -> 4
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp)
val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount) {
val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount, showFavouriteIndicator) {
item.toMediaCardUiModel(
showProgress = false,
showSecondaryMetadata,
showWatchedEpisodeCount,
showMediaTypeIcon = false,
showFavouriteIndicator = showFavouriteIndicator,
)
}
MediaCard(
@@ -107,12 +107,15 @@ internal data class MediaCardUiModel(
/**
* @param showProgress whether a "Resume at …" subtitle should replace the default one when
* the item has a saved position — a row-level choice, not a fact about the item.
* @param showFavouriteIndicator whether favourite state adds a heart to the card. Favourites
* rows turn this off because the surrounding row already communicates that state.
*/
internal fun BaseItem.toMediaCardUiModel(
showProgress: Boolean,
showSecondaryMetadata: Boolean,
showWatchedEpisodeCount: Boolean,
showMediaTypeIcon: Boolean,
showFavouriteIndicator: Boolean,
): MediaCardUiModel {
val position = userData?.playbackPositionTicks ?: 0L
val runtime = runTimeTicks ?: 0L
@@ -128,7 +131,7 @@ internal fun BaseItem.toMediaCardUiModel(
showSecondaryMetadata = showSecondaryMetadata,
contentDescription = cardDescription(this, progress, showWatchedEpisodeCount, showMediaTypeIcon),
played = userData?.played == true,
favourite = isFavorite,
favourite = isFavorite && showFavouriteIndicator,
progress = progress,
isSchedule = isSchedule,
scheduleAvailability = membyAvailability.orEmpty(),
@@ -152,6 +155,7 @@ fun PosterCard(
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
showProgress: Boolean = false,
showFavouriteIndicator: Boolean = true,
) {
val cardsAcross = when (density) {
"compact" -> 8
@@ -159,8 +163,20 @@ fun PosterCard(
else -> 7
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
val model = remember(item, showProgress, showSecondaryMetadata, showWatchedEpisodeCount) {
item.toMediaCardUiModel(showProgress, showSecondaryMetadata, showWatchedEpisodeCount, showMediaTypeIcon = false)
val model = remember(
item,
showProgress,
showSecondaryMetadata,
showWatchedEpisodeCount,
showFavouriteIndicator,
) {
item.toMediaCardUiModel(
showProgress,
showSecondaryMetadata,
showWatchedEpisodeCount,
showMediaTypeIcon = false,
showFavouriteIndicator = showFavouriteIndicator,
)
}
MediaCard(
model, item, width, 2f / 3f, preferPrimary = true, showProgress,
@@ -192,6 +208,7 @@ fun PosterGridCard(
showSecondaryMetadata = true,
showWatchedEpisodeCount,
showMediaTypeIcon,
showFavouriteIndicator = true,
)
}
MediaCard(
@@ -319,12 +319,14 @@ internal fun MediaRow(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
)
MediaCardFormat.LANDSCAPE -> {
LandscapeCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
)
}
}
@@ -69,6 +69,7 @@ import com.ponzischeme89.memby.data.analytics.PlaybackEntryPoint
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.NotificationSurface
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
@@ -4647,12 +4648,12 @@ class PlayerActivity : ComponentActivity() {
}
/**
* Hosts the launcher's service-alert bar over the video.
* Hosts the launcher's service-alert bar over the video when the server permits it.
*
* News about the server is worth more here than anywhere else playback direct-plays
* from Emby, so "the server has stopped communicating" explains a stall the viewer is
* looking at right now. The bar is never focusable, so the transport controls keep the
* remote; it times itself out rather than asking for a press.
* Under Everywhere, news about the server can explain a stall the viewer is looking at
* right now. The bar is never focusable, so the transport controls keep the remote; it
* times itself out rather than asking for a press. Home only and Off remain eligible
* nowhere in this activity through the shared notification rule.
*
* [alertsSuppressed] is what stops an alert being *used up* behind the loading or
* error overlay: the banner records an alert as seen the moment it composes, and the
@@ -4675,6 +4676,7 @@ class PlayerActivity : ComponentActivity() {
.collectAsStateWithLifecycle()
EmbyOutageBanner(suppressed = alertsSuppressed.value)
ServiceAlertBanner(
surface = NotificationSurface.PLAYBACK,
suppressed = alertsSuppressed.value || outage != null,
)
}
@@ -15,6 +15,30 @@ class ServiceAlertTest {
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
@Test
fun `home only allows browsing notifications but not playback overlays`() {
assertTrue(notificationEligible(NotificationDisplayPolicy.HOME_ONLY, NotificationSurface.BROWSING))
assertFalse(notificationEligible(NotificationDisplayPolicy.HOME_ONLY, NotificationSurface.PLAYBACK))
}
@Test
fun `everywhere allows both notification surfaces`() {
assertTrue(notificationEligible(NotificationDisplayPolicy.EVERYWHERE, NotificationSurface.BROWSING))
assertTrue(notificationEligible(NotificationDisplayPolicy.EVERYWHERE, NotificationSurface.PLAYBACK))
}
@Test
fun `off prevents notifications on every surface`() {
assertFalse(notificationEligible(NotificationDisplayPolicy.OFF, NotificationSurface.BROWSING))
assertFalse(notificationEligible(NotificationDisplayPolicy.OFF, NotificationSurface.PLAYBACK))
}
@Test
fun `missing and unknown server policy fail safe to home only`() {
assertEquals(NotificationDisplayPolicy.HOME_ONLY, NotificationDisplayPolicy.fromWire(null))
assertEquals(NotificationDisplayPolicy.HOME_ONLY, NotificationDisplayPolicy.fromWire("unexpected"))
}
@Test
fun `picks the first alert this tv has not seen`() {
val chosen = firstUnseenAlert(
@@ -68,6 +92,7 @@ class ServiceAlertTest {
"""{"maintenance":false,"message":""}""",
)
assertTrue(status.alerts.isEmpty())
assertEquals("home_only", status.notificationDisplay)
}
@Test
@@ -89,6 +114,18 @@ class ServiceAlertTest {
assertEquals("", alert.label)
}
@Test
fun `status decodes the server notification display policy`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"notificationDisplay":"everywhere"}""",
)
assertEquals(
NotificationDisplayPolicy.EVERYWHERE,
NotificationDisplayPolicy.fromWire(status.notificationDisplay),
)
}
@Test
fun `status decodes a radarr import alert with its own wording`() {
val status = json.decodeFromString<GatewayServiceStatus>(
@@ -22,11 +22,17 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -48,6 +54,7 @@ import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class HomeMovieHeroScreenshotTest {
@get:Rule
val compose = createComposeRule()
@@ -62,6 +69,25 @@ class HomeMovieHeroScreenshotTest {
capture("df_home-movie-hero", movies)
}
@Test
fun `hero focus treatment matches featured and mini cards`() {
setHeroContent(movies)
compose.onNodeWithContentDescription("Featured, NEW RELEASE, The Last Horizon")
.requestFocus()
.assertIsFocused()
compose.onRoot().captureRoboImage(
"build/screenshots/home-movie-hero/featured-focused.png",
)
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
compose.onNodeWithContentDescription("POPULAR movie, Midnight Signal")
.assertIsFocused()
compose.onRoot().captureRoboImage(
"build/screenshots/home-movie-hero/mini-focused.png",
)
}
/**
* The case the audit reproduced: with a title long enough to wrap, the card's content
* column used to overflow its fixed height around the individual Play chip. The hero
@@ -287,6 +313,11 @@ class HomeMovieHeroScreenshotTest {
}
private fun capture(name: String, movies: List<HomeHeroPick>) {
setHeroContent(movies)
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
}
private fun setHeroContent(movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
@@ -364,7 +395,6 @@ class HomeMovieHeroScreenshotTest {
}
assertTrue(compose.onAllNodesWithText("Play").fetchSemanticsNodes().isEmpty())
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
}
private val movies = listOf(
@@ -0,0 +1,33 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaCardUiModelTest {
private val favourite = BaseItem(
id = "favourite",
userData = UserItemData(isFavorite = true),
)
@Test
fun `favourite indicator remains visible on ordinary browsing cards`() {
assertTrue(favourite.cardModel(showFavouriteIndicator = true).favourite)
}
@Test
fun `favourite indicator can be hidden when the browsing context is favourites`() {
assertFalse(favourite.cardModel(showFavouriteIndicator = false).favourite)
}
private fun BaseItem.cardModel(showFavouriteIndicator: Boolean): MediaCardUiModel =
toMediaCardUiModel(
showProgress = false,
showSecondaryMetadata = true,
showWatchedEpisodeCount = false,
showMediaTypeIcon = false,
showFavouriteIndicator = showFavouriteIndicator,
)
}