0.3.49
This commit is contained in:
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.39"
|
||||
val defaultVersionName = "0.3.40"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -2145,10 +2145,24 @@ class EmbyRepository internal constructor(
|
||||
* Uploads a batch of row-engagement events. Silent on the direct path (nothing is
|
||||
* listening) and silent on failure — telemetry must never surface on a TV.
|
||||
*/
|
||||
fun reportRowEvents(events: List<GatewayRowEvent>) {
|
||||
if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
|
||||
fun reportRowEvents(
|
||||
events: List<GatewayRowEvent>,
|
||||
onUnsent: (List<GatewayRowEvent>) -> Unit = {},
|
||||
) {
|
||||
if (events.isEmpty()) return
|
||||
// Nothing is ever listening on the direct path, so drop rather than re-buffer.
|
||||
if (!ServerConfig.isGateway) return
|
||||
if (snapshot.token.isNullOrBlank()) {
|
||||
// A session being (re)established: hand the batch back so it is retried on the
|
||||
// next flush rather than dropped by the caller's drain.
|
||||
onUnsent(events)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
runCatching { requireGateway().reportRowEvents(GatewayRowEvents(events)) }
|
||||
val delivered = runCatching {
|
||||
requireGateway().reportRowEvents(GatewayRowEvents(events))
|
||||
}.isSuccess
|
||||
if (!delivered) onUnsent(events)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,13 @@ class RowAnalytics(
|
||||
*/
|
||||
fun rowFocused(rowId: String, rowKind: String, itemId: String) {
|
||||
synchronized(lock) {
|
||||
if (focusedRowId == rowId) return
|
||||
closeOpenFocus()
|
||||
if (focusedRowId == rowId) {
|
||||
// Still the same strip — extend the dwell, but remember the card the
|
||||
// remote actually came to rest on rather than the one it entered on.
|
||||
lastFocusedItemId = itemId
|
||||
return
|
||||
}
|
||||
closeOpenFocus(reArm = false)
|
||||
focusedRowId = rowId
|
||||
focusedRowKind = rowKind
|
||||
focusStartedAt = now()
|
||||
@@ -92,11 +97,34 @@ class RowAnalytics(
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open focus measurement — call when leaving the home screen, or before a
|
||||
* flush, so dwell is not lost while the viewer sits on one row.
|
||||
* Closes the open focus measurement outright — call when focus genuinely leaves the
|
||||
* rows (the rail, the hero, an overlay, a selection) or the home screen is left.
|
||||
*/
|
||||
fun endFocus() {
|
||||
synchronized(lock) { closeOpenFocus() }
|
||||
synchronized(lock) { closeOpenFocus(reArm = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the dwell accrued so far and keeps measuring the same row. Used by the periodic
|
||||
* flush: a viewer who sits on one row for two minutes should contribute the whole two
|
||||
* minutes, not one chunk ending at the first flush and nothing after it until they
|
||||
* move to a different row.
|
||||
*/
|
||||
fun checkpointFocus() {
|
||||
synchronized(lock) { closeOpenFocus(reArm = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns events to the buffer after an upload could not deliver them, so a transient
|
||||
* network failure costs a delay rather than the batch. Bounded like every other write:
|
||||
* if the buffer is now over capacity the oldest events are dropped.
|
||||
*/
|
||||
fun restore(events: List<GatewayRowEvent>) {
|
||||
if (events.isEmpty()) return
|
||||
synchronized(lock) {
|
||||
buffer.addAll(0, events)
|
||||
while (buffer.size > maxBuffered) buffer.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns everything buffered and clears it. */
|
||||
@@ -116,28 +144,37 @@ class RowAnalytics(
|
||||
impressed.clear()
|
||||
impressedItems.clear()
|
||||
focusedRowId = null
|
||||
focusStartedAt = 0
|
||||
lastFocusedItemId = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var lastFocusedItemId: String = ""
|
||||
|
||||
private fun closeOpenFocus() {
|
||||
/**
|
||||
* @param reArm keep measuring the same row from now, rather than clearing it. A
|
||||
* checkpoint flush re-arms; a genuine departure does not.
|
||||
*/
|
||||
private fun closeOpenFocus(reArm: Boolean) {
|
||||
val rowId = focusedRowId ?: return
|
||||
val dwell = (now() - focusStartedAt).coerceAtLeast(0)
|
||||
focusedRowId = null
|
||||
// Sub-second glances are D-pad travel, not attention. Dropping them keeps the
|
||||
// numbers meaningful and the batches small.
|
||||
if (dwell < MIN_DWELL_MS) return
|
||||
add(
|
||||
GatewayRowEvent(
|
||||
rowId = rowId,
|
||||
rowKind = focusedRowKind,
|
||||
event = EVENT_FOCUS,
|
||||
itemId = lastFocusedItemId,
|
||||
dwellMs = dwell,
|
||||
occurredAt = timestamp(),
|
||||
),
|
||||
)
|
||||
// Sub-threshold glances are D-pad travel, not attention. Drop them — and, when
|
||||
// re-arming, leave the clock running so the unrecorded fraction is not discarded
|
||||
// on every flush.
|
||||
if (dwell >= MIN_DWELL_MS) {
|
||||
add(
|
||||
GatewayRowEvent(
|
||||
rowId = rowId,
|
||||
rowKind = focusedRowKind,
|
||||
event = EVENT_FOCUS,
|
||||
itemId = lastFocusedItemId,
|
||||
dwellMs = dwell,
|
||||
occurredAt = timestamp(),
|
||||
),
|
||||
)
|
||||
focusStartedAt = now()
|
||||
}
|
||||
if (!reArm) focusedRowId = null
|
||||
}
|
||||
|
||||
/** Caller already holds the lock. */
|
||||
|
||||
@@ -498,6 +498,13 @@ data class BaseItem(
|
||||
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
|
||||
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
|
||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
||||
// Set by the gateway on a Continue Watching card for a title this viewer requested that
|
||||
// has just become watchable, so it can wear a REQUEST READY tag until they start it.
|
||||
// The label is the server's wording (the MembyAirLabel precedent); the flag alone is
|
||||
// enough for an older build, which falls back to its own constant. Both default, so a
|
||||
// cached home payload decodes unchanged.
|
||||
@SerialName("MembyRequestReady") val membyRequestReady: Boolean = false,
|
||||
@SerialName("MembyRequestReadyLabel") val membyRequestReadyLabel: String? = null,
|
||||
@SerialName("MembySearchState") val membySearchState: String? = null,
|
||||
@SerialName("MembyRequestable") val membyRequestable: Boolean = false,
|
||||
@SerialName("MembyPosterURL") val membyPosterUrl: String? = null,
|
||||
|
||||
@@ -176,6 +176,12 @@ data class HomeRow(
|
||||
val title: String = "",
|
||||
val kind: String = "",
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
/**
|
||||
* Operator-pinned card shape: "poster" forces upright poster cards, "thumb" forces the
|
||||
* wide landscape cards Continue Watching uses. Empty (the default, and every row from a
|
||||
* gateway that predates this) leaves the client's automatic choice alone.
|
||||
*/
|
||||
val layout: String = "",
|
||||
)
|
||||
|
||||
/** Everything the launcher renders, in one response. */
|
||||
|
||||
@@ -271,6 +271,12 @@ internal fun HomeContentPane(
|
||||
// back. Keyed on the destination so arriving at Home never inherits where
|
||||
// focus happened to be on another one.
|
||||
var rowFocusedBelowHero by remember(homeState.selectedDestination) { mutableStateOf(false) }
|
||||
// The moment focus is no longer on a shelf — up to the hero, across to My Shows, or a
|
||||
// destination switch — close the open dwell measurement, so minutes spent above the
|
||||
// rows are not credited to whichever row was focused last.
|
||||
LaunchedEffect(rowFocusedBelowHero) {
|
||||
if (!rowFocusedBelowHero) homeViewModel.notifyFocusLeftRows()
|
||||
}
|
||||
val showHomeHero = shouldShowHomeMovieHero(
|
||||
hasMovies = hasContextualHero,
|
||||
listAtTop = homeListAtTop,
|
||||
|
||||
@@ -616,6 +616,7 @@ internal fun serverHomeRows(
|
||||
"continue" -> CONTINUE_ROW_SECONDARY_METADATA
|
||||
else -> BROWSE_ROW_SECONDARY_METADATA
|
||||
},
|
||||
cardLayout = row.layout,
|
||||
)
|
||||
}
|
||||
// A server response is already context-ranked. Do not reapply the static bundled
|
||||
|
||||
@@ -654,7 +654,7 @@ internal fun HomeScreen(
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, homeViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics()
|
||||
if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics(endMeasurement = true)
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
@@ -683,6 +683,9 @@ internal fun HomeScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onRailFocusChanged = {
|
||||
homeState.navigationExpanded = it
|
||||
// Focus on the rail is focus off the shelves: end the open row dwell so
|
||||
// it stops accruing while the viewer is in the navigation or an overlay.
|
||||
if (it) homeViewModel.notifyFocusLeftRows()
|
||||
},
|
||||
onDestinationSelected = { destination ->
|
||||
homeViewModel.trackJourney(
|
||||
|
||||
@@ -262,17 +262,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
||||
analytics.rowFocused(rowId, rowKind, itemId)
|
||||
|
||||
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) =
|
||||
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) {
|
||||
analytics.rowSelected(rowId, rowKind, itemId)
|
||||
// Opening something ends the current dwell — the viewer has left the row for a
|
||||
// detail page, so time on that page must not be added to the row's total.
|
||||
analytics.endFocus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open dwell measurement and uploads. Called on a timer and when the home
|
||||
* screen stops, so time spent sitting on one row is not lost.
|
||||
* Focus has moved off the shelves — to the rail, the hero, My Shows or an overlay.
|
||||
* Closes the open dwell measurement so it stops accruing away from the rows.
|
||||
*/
|
||||
fun flushAnalytics() {
|
||||
fun notifyFocusLeftRows() = analytics.endFocus()
|
||||
|
||||
/**
|
||||
* Emits accrued dwell and uploads. On a timer it *checkpoints* — a viewer sitting on
|
||||
* one row keeps accruing dwell across flushes rather than contributing a single ~20s
|
||||
* chunk until they move — and [endMeasurement] closes it outright for the cases where
|
||||
* the home screen is genuinely being left. A batch the upload cannot deliver is put
|
||||
* back for the next flush rather than lost to a transient network failure.
|
||||
*/
|
||||
fun flushAnalytics(endMeasurement: Boolean = false) {
|
||||
if (analyticsPausedForPlayback) return
|
||||
analytics.endFocus()
|
||||
repository.reportRowEvents(analytics.drain())
|
||||
if (endMeasurement) analytics.endFocus() else analytics.checkpointFocus()
|
||||
repository.reportRowEvents(analytics.drain()) { unsent -> analytics.restore(unsent) }
|
||||
repository.reportJourneyEvents(journey.drain())
|
||||
}
|
||||
|
||||
@@ -310,7 +323,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
*/
|
||||
val journeySink: JourneySink get() = journey
|
||||
|
||||
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
|
||||
fun endJourney(screen: String) { journey.end(screen); flushAnalytics(endMeasurement = true) }
|
||||
|
||||
suspend fun endJourneyBeforeProfileSwitch(screen: String) {
|
||||
analytics.endFocus()
|
||||
@@ -991,7 +1004,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
analyticsPausedForPlayback = false
|
||||
flushAnalytics()
|
||||
flushAnalytics(endMeasurement = true)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
@@ -69,6 +70,12 @@ data class ResumableMediaCardModel(
|
||||
val played: Boolean = false,
|
||||
val favourite: Boolean = false,
|
||||
val isNextUp: Boolean = false,
|
||||
/**
|
||||
* Non-null when this card is a title the viewer requested that has just become
|
||||
* watchable. Holds the tag wording, which the gateway supplies; [REQUEST_READY_TAG] is
|
||||
* the fallback for a payload from a gateway that predates the label field.
|
||||
*/
|
||||
val requestReadyTag: String? = null,
|
||||
) {
|
||||
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
|
||||
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
|
||||
@@ -76,6 +83,9 @@ data class ResumableMediaCardModel(
|
||||
val nextUpLabel: String? get() = "Next up".takeIf { isNextUp }
|
||||
}
|
||||
|
||||
/** Client fallback for the request-ready tag when the gateway sent no wording. */
|
||||
const val REQUEST_READY_TAG = "REQUEST READY"
|
||||
|
||||
internal fun BaseItem.toResumableMediaCardModel(
|
||||
thumbUrl: String?,
|
||||
backdropUrl: String?,
|
||||
@@ -102,6 +112,11 @@ internal fun BaseItem.toResumableMediaCardModel(
|
||||
primaryUrl = primaryUrl,
|
||||
played = played,
|
||||
favourite = isFavorite,
|
||||
requestReadyTag = if (membyRequestReady) {
|
||||
membyRequestReadyLabel?.trim()?.takeIf(String::isNotEmpty) ?: REQUEST_READY_TAG
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// This adapter is used by the Continue Watching card. Within that merged row, an
|
||||
// unplayed episode with no playhead is the Next Up half; resumable episodes have a
|
||||
// positive playhead and films are never supplied by Emby's Next Up feed.
|
||||
@@ -333,6 +348,22 @@ fun ResumableMediaCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
model.requestReadyTag?.let { tag ->
|
||||
Text(
|
||||
text = tag,
|
||||
color = MembyAccentInk,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MembyAccent)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
if (focused) MembyArtworkPlayCue(Modifier.align(Alignment.Center))
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -63,6 +63,11 @@ data class HomeBrowseRow(
|
||||
val emptyMessage: String,
|
||||
val showSecondaryMetadata: Boolean = true,
|
||||
val showWatchedEpisodeCount: Boolean = false,
|
||||
/**
|
||||
* Operator-pinned card shape from the gateway's section definitions: "poster" or
|
||||
* "thumb". Empty leaves [cardFormat]'s automatic choice alone.
|
||||
*/
|
||||
val cardLayout: String = "",
|
||||
)
|
||||
|
||||
private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
|
||||
@@ -175,8 +180,12 @@ internal fun MediaRow(
|
||||
|
||||
when {
|
||||
row.items.isEmpty() && row.loading -> {
|
||||
val isPortrait = remember(row.kind) {
|
||||
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
|
||||
val isPortrait = remember(row.kind, row.cardLayout) {
|
||||
when (row.cardLayout) {
|
||||
"poster" -> true
|
||||
"thumb", "landscape" -> false
|
||||
else -> row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(
|
||||
@@ -226,7 +235,7 @@ internal fun MediaRow(
|
||||
items = row.items,
|
||||
key = { _, item -> item.id },
|
||||
contentType = { _, item ->
|
||||
if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
|
||||
if (cardFormat(row.kind, item, artworkStyle, row.cardLayout) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
|
||||
},
|
||||
) { index, item ->
|
||||
val targetFocusRequester = when {
|
||||
@@ -266,8 +275,8 @@ internal fun MediaRow(
|
||||
{ currentOnItemLongPressed(currentItem) }
|
||||
}
|
||||
|
||||
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle) {
|
||||
cardFormat(row.kind, item, artworkStyle)
|
||||
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle, row.cardLayout) {
|
||||
cardFormat(row.kind, item, artworkStyle, row.cardLayout)
|
||||
}
|
||||
|
||||
if (row.kind == MediaRowKind.CONTINUE) {
|
||||
@@ -314,18 +323,29 @@ internal fun MediaRow(
|
||||
}
|
||||
}
|
||||
|
||||
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
|
||||
internal enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
|
||||
|
||||
private fun cardFormat(
|
||||
/**
|
||||
* Resolves a card's shape. A per-row [rowLayout] pinned by the operator ("poster" /
|
||||
* "thumb") wins over the viewer's global [artworkStyle], which in turn wins over the
|
||||
* automatic rule (Continue Watching and episodes landscape, everything else poster).
|
||||
* Pure so [MediaRowCardFormatTest] can pin the precedence.
|
||||
*/
|
||||
internal fun cardFormat(
|
||||
kind: MediaRowKind,
|
||||
item: BaseItem,
|
||||
artworkStyle: String = "automatic",
|
||||
): MediaCardFormat = when (artworkStyle) {
|
||||
rowLayout: String = "",
|
||||
): MediaCardFormat = when (rowLayout) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when {
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
"thumb", "landscape" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when (artworkStyle) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when {
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,72 @@ class RowAnalyticsTest {
|
||||
assertEquals(6_000L, focuses.single().dwellMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a checkpoint emits dwell and keeps measuring the same row`() {
|
||||
val clock = FakeClock()
|
||||
val collector = analytics(clock)
|
||||
|
||||
collector.rowFocused("recommended", "MOVIES", "item-1")
|
||||
clock.advance(20_000)
|
||||
collector.checkpointFocus()
|
||||
clock.advance(20_000)
|
||||
collector.checkpointFocus()
|
||||
clock.advance(5_000)
|
||||
collector.endFocus()
|
||||
|
||||
val dwell = collector.drain()
|
||||
.filter { it.event == RowAnalytics.EVENT_FOCUS }
|
||||
.sumOf { it.dwellMs }
|
||||
// The whole 45s is credited to the row, not just the first chunk.
|
||||
assertEquals(45_000L, dwell)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a checkpoint below the threshold does not discard the accrued fraction`() {
|
||||
val clock = FakeClock()
|
||||
val collector = analytics(clock)
|
||||
|
||||
collector.rowFocused("recommended", "MOVIES", "item-1")
|
||||
clock.advance(300)
|
||||
collector.checkpointFocus() // below MIN_DWELL_MS — nothing emitted, clock left running
|
||||
clock.advance(300)
|
||||
collector.endFocus()
|
||||
|
||||
val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS }
|
||||
assertEquals(600L, focus.dwellMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored events are retried on the next drain`() {
|
||||
val collector = analytics(FakeClock())
|
||||
collector.rowImpression("favorites", "FAVORITES")
|
||||
|
||||
val undelivered = collector.drain()
|
||||
assertEquals(1, undelivered.size)
|
||||
collector.restore(undelivered)
|
||||
|
||||
collector.rowImpression("recommended", "MOVIES")
|
||||
assertEquals(
|
||||
listOf("favorites", "recommended"),
|
||||
collector.drain().map { it.rowId },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored events past capacity drop the oldest`() {
|
||||
val collector = analytics(FakeClock(), maxBuffered = 2)
|
||||
val stale = List(3) {
|
||||
com.ponzischeme89.memby.data.model.GatewayRowEvent(
|
||||
rowId = "stale-$it", rowKind = "MOVIES",
|
||||
event = RowAnalytics.EVENT_IMPRESSION, occurredAt = "x",
|
||||
)
|
||||
}
|
||||
|
||||
collector.restore(stale)
|
||||
|
||||
assertEquals(listOf("stale-1", "stale-2"), collector.drain().map { it.rowId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passing through a row is not counted as attention`() {
|
||||
val clock = FakeClock()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** The precedence in [cardFormat]: an operator's per-row pin beats the viewer's global
|
||||
* artwork style, which beats the automatic episode/poster rule. */
|
||||
class MediaRowCardFormatTest {
|
||||
private val movie = BaseItem(id = "m", name = "A Film", type = "Movie")
|
||||
private val episode = BaseItem(id = "e", name = "Pilot", type = "Episode")
|
||||
|
||||
@Test
|
||||
fun automaticRuleWhenNothingIsPinned() {
|
||||
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, movie))
|
||||
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.MOVIES, episode))
|
||||
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.CONTINUE, movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowLayoutOverridesEverything() {
|
||||
assertEquals(
|
||||
MediaCardFormat.LANDSCAPE,
|
||||
cardFormat(MediaRowKind.FAVORITES, movie, artworkStyle = "poster", rowLayout = "thumb"),
|
||||
)
|
||||
assertEquals(
|
||||
MediaCardFormat.PORTRAIT,
|
||||
cardFormat(MediaRowKind.CONTINUE, episode, artworkStyle = "backdrop", rowLayout = "poster"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun globalArtworkStyleStillAppliesWithoutARowPin() {
|
||||
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.MOVIES, movie, artworkStyle = "backdrop"))
|
||||
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, episode, artworkStyle = "poster"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRowLayoutFallsThrough() {
|
||||
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, movie, rowLayout = "sideways"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* The REQUEST READY tag on a Continue Watching card. The claim it makes — that the tag reads
|
||||
* as a temporary marker rather than as part of the artwork, and sits clear of the watched /
|
||||
* favourite icons in the opposite corner — is not something a unit test can check.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class RequestReadyCardScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a freshly arrived request wears the request ready tag`() {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) {
|
||||
Box(Modifier.fillMaxSize().padding(48.dp)) {
|
||||
ContinueWatchingCard(
|
||||
item = BaseItem(
|
||||
id = "requested-film",
|
||||
name = "The Slow Return",
|
||||
type = "Movie",
|
||||
runTimeTicks = 108L * 60L * 10_000_000L,
|
||||
userData = UserItemData(playbackPositionTicks = 0L),
|
||||
membyRequestReady = true,
|
||||
membyRequestReadyLabel = "REQUEST READY",
|
||||
),
|
||||
availableWidth = 864.dp,
|
||||
portraitArtwork = false,
|
||||
onFocused = {},
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("REQUEST READY").assertExists()
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/request-ready/continue-watching-tag.png",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,35 @@ class ResumableMediaCardTest {
|
||||
assertNull(model.nextUpLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a request-ready title carries the gateway's tag wording`() {
|
||||
val model = BaseItem(
|
||||
id = "film",
|
||||
name = "A Requested Film",
|
||||
type = "Movie",
|
||||
membyRequestReady = true,
|
||||
membyRequestReadyLabel = "READY FOR YOU",
|
||||
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertEquals("READY FOR YOU", model.requestReadyTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a request-ready title with no wording falls back to the client constant`() {
|
||||
val model = BaseItem(id = "film", name = "A Film", type = "Movie", membyRequestReady = true)
|
||||
.toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertEquals(REQUEST_READY_TAG, model.requestReadyTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary card has no request-ready tag`() {
|
||||
val model = BaseItem(id = "film", name = "A Film", type = "Movie")
|
||||
.toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertNull(model.requestReadyTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `landscape artwork prefers thumb over backdrop and primary`() {
|
||||
val model = ResumableMediaCardModel(
|
||||
|
||||
Reference in New Issue
Block a user