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