0.3.07
This commit is contained in:
@@ -62,7 +62,7 @@ val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
val defaultVersionName = "0.3.06"
|
||||
val defaultVersionName = "0.3.07"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -92,6 +92,7 @@ class MaintenanceMonitor(
|
||||
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _seriesStatusRevision = MutableStateFlow(0L)
|
||||
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
@@ -136,6 +137,12 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
|
||||
|
||||
/**
|
||||
* Revision of the server's persisted Sonarr lifecycle catalogue. Detail metadata is
|
||||
* process-cached, so a changed value tells the launcher to discard old series facts.
|
||||
*/
|
||||
val seriesStatusRevision: StateFlow<Long> = _seriesStatusRevision.asStateFlow()
|
||||
|
||||
/** One household-wide composition, delivered by the status poll to every viewer. */
|
||||
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
|
||||
val metadataHeroTimeRemainingColour: StateFlow<String> =
|
||||
@@ -288,6 +295,7 @@ class MaintenanceMonitor(
|
||||
// clearing it here would fight that loop for the same flow.
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_seriesStatusRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
@@ -327,6 +335,7 @@ class MaintenanceMonitor(
|
||||
null
|
||||
}
|
||||
_preferencesRevision.value = status.preferencesRevision
|
||||
_seriesStatusRevision.value = status.seriesStatusRevision
|
||||
_metadataHeroContentOrder.value = status.metadataHeroContentOrder
|
||||
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
||||
.distinct()
|
||||
@@ -382,6 +391,7 @@ class MaintenanceMonitor(
|
||||
_compatibility.value = null
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_seriesStatusRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
|
||||
@@ -416,9 +416,9 @@ data class BaseItem(
|
||||
@SerialName("SeriesId") val seriesId: String? = null,
|
||||
@SerialName("SeriesName") val seriesName: String? = null,
|
||||
/**
|
||||
* Emby's production status for a series — "Continuing" or "Ended". Only the detail
|
||||
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
|
||||
* absence as "not known to be running" rather than guessing either way.
|
||||
* Canonical production status for a series. In gateway mode the detail response
|
||||
* replaces Emby's value with the latest stored Sonarr observation where one matches;
|
||||
* direct mode keeps Emby as the only source.
|
||||
*/
|
||||
@SerialName("Status") val status: String? = null,
|
||||
// Emby returns these on episodes without being asked, so they cost no extra Fields.
|
||||
@@ -504,15 +504,15 @@ data class BaseItem(
|
||||
val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
|
||||
* one, since it knows about a season announced but not yet imported; Emby's own
|
||||
* [status] is the fallback and the only source the direct path has. Neither present
|
||||
* means no, which keeps the pace estimate saying "finish" — the weaker claim.
|
||||
* Whether more episodes are expected. The canonical detail [status] wins; schedule
|
||||
* lifecycle metadata is only the opening-frame fallback before that record arrives.
|
||||
* Neither present means no, which keeps the pace estimate saying "finish" — the weaker
|
||||
* claim.
|
||||
*/
|
||||
val isOngoingSeries: Boolean
|
||||
get() = when {
|
||||
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
|
||||
else -> status.equals("Continuing", ignoreCase = true)
|
||||
status != null -> status.equals("Continuing", ignoreCase = true)
|
||||
else -> membyLifecycle.equals("continuing", ignoreCase = true)
|
||||
}
|
||||
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
||||
|
||||
|
||||
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
|
||||
* on the poll the app is already making.
|
||||
*/
|
||||
val preferencesRevision: Long = 0,
|
||||
/** Monotonic revision of the gateway's stored Sonarr series lifecycle catalogue. */
|
||||
val seriesStatusRevision: Long = 0,
|
||||
/** Household-wide order of the focused metadata hero's content blocks. */
|
||||
val metadataHeroContentOrder: List<String> = emptyList(),
|
||||
/** Household-wide colour for the optional time-remaining caption: green or white. */
|
||||
|
||||
@@ -324,6 +324,7 @@ internal data class DetailsReturn(
|
||||
internal fun FocusedDetailsOverlay(
|
||||
homeViewModel: HomeViewModel,
|
||||
selected: BaseItem,
|
||||
seriesStatusRevision: Long = 0,
|
||||
restorePosition: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onPlayTrailer: (BaseItem) -> Unit,
|
||||
@@ -345,12 +346,20 @@ internal fun FocusedDetailsOverlay(
|
||||
// Detail metadata belongs to this overlay and this item id. Launcher/Search focus is
|
||||
// still consulted for live user state, but it cannot replace the full record with the
|
||||
// lightweight Search card when focus returns behind the overlay.
|
||||
var detailMetadata by remember(selected.id) {
|
||||
mutableStateOf(homeViewModel.detailMetadataSnapshot(selected.id))
|
||||
var detailMetadata by remember(selected.id, seriesStatusRevision) {
|
||||
mutableStateOf(
|
||||
homeViewModel.detailMetadataSnapshot(selected.id, seriesStatusRevision),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(selected.id, selected.isRadarrOnly, selected.isSchedule) {
|
||||
LaunchedEffect(
|
||||
selected.id,
|
||||
selected.isRadarrOnly,
|
||||
selected.isSchedule,
|
||||
seriesStatusRevision,
|
||||
) {
|
||||
if (!selected.isRadarrOnly && !selected.isSchedule) {
|
||||
homeViewModel.loadDetailMetadata(selected.id)?.let { detailMetadata = it }
|
||||
homeViewModel.loadDetailMetadata(selected.id, seriesStatusRevision)
|
||||
?.let { detailMetadata = it }
|
||||
}
|
||||
}
|
||||
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
|
||||
|
||||
@@ -261,6 +261,8 @@ internal fun HomeScreen(
|
||||
)
|
||||
}
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val seriesStatusRevision by
|
||||
ServiceLocator.maintenance.seriesStatusRevision.collectAsStateWithLifecycle()
|
||||
val metadataHeroContentOrder by
|
||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||
val metadataHeroTimeRemainingColour by
|
||||
@@ -2193,6 +2195,7 @@ internal fun HomeScreen(
|
||||
FocusedDetailsOverlay(
|
||||
homeViewModel = homeViewModel,
|
||||
selected = selected,
|
||||
seriesStatusRevision = seriesStatusRevision,
|
||||
restorePosition = restoreDetailPosition,
|
||||
airingNotice = detailsAiringNotice,
|
||||
onOpenItem = { related ->
|
||||
|
||||
@@ -177,6 +177,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
|
||||
}
|
||||
private val metadataInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
|
||||
private var seriesStatusRevision = 0L
|
||||
|
||||
/** Row engagement, buffered here and uploaded in batches. */
|
||||
private val analytics = RowAnalytics()
|
||||
@@ -637,8 +638,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
* into it. A detail overlay uses this for its opening frame, then [loadDetailMetadata]
|
||||
* joins an existing request or starts the missing one.
|
||||
*/
|
||||
internal fun detailMetadataSnapshot(itemId: String): BaseItem? =
|
||||
synchronized(metadataCache) { metadataCache[itemId] }
|
||||
internal fun detailMetadataSnapshot(itemId: String, statusRevision: Long = 0): BaseItem? =
|
||||
synchronized(metadataCache) {
|
||||
acceptSeriesStatusRevision(statusRevision)
|
||||
metadataCache[itemId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one full item record, shared by focus prefetch and the detail overlay.
|
||||
@@ -649,10 +653,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
* reopened detail page relies on. The request lives in [viewModelScope], so losing
|
||||
* focus or closing the first page does not cancel work a reopened page is awaiting.
|
||||
*/
|
||||
internal suspend fun loadDetailMetadata(itemId: String): BaseItem? {
|
||||
internal suspend fun loadDetailMetadata(itemId: String, statusRevision: Long = 0): BaseItem? {
|
||||
if (itemId.isBlank()) return null
|
||||
var cached: BaseItem? = null
|
||||
val request = synchronized(metadataCache) {
|
||||
acceptSeriesStatusRevision(statusRevision)
|
||||
cached = metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }
|
||||
if (cached != null) {
|
||||
null
|
||||
@@ -663,8 +668,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
return cached ?: request?.await()
|
||||
}
|
||||
|
||||
/** Must be called while synchronised on [metadataCache]. */
|
||||
private fun acceptSeriesStatusRevision(revision: Long) {
|
||||
if (revision <= 0L || revision == seriesStatusRevision) return
|
||||
seriesStatusRevision = revision
|
||||
metadataInFlight.values.forEach { it.cancel() }
|
||||
metadataInFlight.clear()
|
||||
metadataCache.clear()
|
||||
}
|
||||
|
||||
private fun newDetailMetadataRequest(itemId: String): Deferred<BaseItem?> {
|
||||
val request = viewModelScope.async(
|
||||
val revisionAtStart = seriesStatusRevision
|
||||
lateinit var request: Deferred<BaseItem?>
|
||||
request = viewModelScope.async(
|
||||
context = Dispatchers.IO,
|
||||
start = CoroutineStart.LAZY,
|
||||
) {
|
||||
@@ -672,10 +688,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
runCatching { repository.getItemDetails(itemId) }
|
||||
.getOrNull()
|
||||
?.also { details ->
|
||||
synchronized(metadataCache) { metadataCache[itemId] = details }
|
||||
synchronized(metadataCache) {
|
||||
if (seriesStatusRevision == revisionAtStart) {
|
||||
metadataCache[itemId] = details
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized(metadataCache) { metadataInFlight.remove(itemId) }
|
||||
synchronized(metadataCache) {
|
||||
if (metadataInFlight[itemId] === request) {
|
||||
metadataInFlight.remove(itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
metadataInFlight[itemId] = request
|
||||
|
||||
@@ -385,14 +385,13 @@ internal fun SeriesDetailContent(
|
||||
// lists, and a new identity for them on every focus move recomposes the fact row.
|
||||
val heroFacts = remember(
|
||||
item.id,
|
||||
seasons.size,
|
||||
item.productionYear,
|
||||
item.officialRating,
|
||||
item.runTimeTicks,
|
||||
item.status,
|
||||
item.membyLifecycle,
|
||||
item.membyLifecycleText,
|
||||
) {
|
||||
heroFacts(item, seasons.size)
|
||||
heroFacts(item)
|
||||
}
|
||||
val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
|
||||
|
||||
|
||||
@@ -64,21 +64,18 @@ fun remainingLabel(item: BaseItem): String? {
|
||||
}
|
||||
|
||||
/**
|
||||
* The quiet line directly under the title: year, length, certificate and, for a series,
|
||||
* its production status. The score sits beside the title and the genres are a credit row,
|
||||
* so non-series titles stay at three items and never have to compete for the width.
|
||||
*
|
||||
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
|
||||
* The quiet line directly under the title. A series uses the stable three-part contract
|
||||
* year, episode runtime and production status; other media keeps its certificate there.
|
||||
* The score sits beside the title and the genres are a credit row.
|
||||
*/
|
||||
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
||||
fun heroFacts(item: BaseItem): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
if (seasonCount > 0) {
|
||||
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
if (item.isSeries) {
|
||||
seriesStatusLabel(item)?.let(::add)
|
||||
} else {
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
seriesStatusLabel(item)?.let(::add)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,14 +87,15 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
||||
*/
|
||||
fun seriesStatusLabel(item: BaseItem): String? {
|
||||
if (!item.isSeries) return null
|
||||
val status = item.membyLifecycleText?.takeIf(String::isNotBlank)
|
||||
val status = item.status?.takeIf(String::isNotBlank)
|
||||
?: item.membyLifecycleText?.takeIf(String::isNotBlank)
|
||||
?: item.membyLifecycle?.takeIf(String::isNotBlank)
|
||||
?: item.status?.takeIf(String::isNotBlank)
|
||||
?: return null
|
||||
return when (status.trim().lowercase(Locale.US)) {
|
||||
"continuing" -> "Continuing"
|
||||
"ended" -> "Ended"
|
||||
"cancelled", "canceled" -> "Cancelled"
|
||||
"upcoming" -> "Upcoming"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ui.TitleLogoImage
|
||||
import com.ponzischeme89.memby.ui.cinematicBackdropPullBack
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||
|
||||
/** The already-resolved metadata needed to render a pause without doing repository work. */
|
||||
internal data class PauseMediaHeroMetadata(
|
||||
val title: String = "",
|
||||
val seriesName: String? = null,
|
||||
val episodeCode: String? = null,
|
||||
val overview: String = "",
|
||||
val logoUrl: String? = null,
|
||||
val backdropUrl: String? = null,
|
||||
val primaryArtworkUrl: String? = null,
|
||||
) {
|
||||
val isEpisode: Boolean get() = !episodeCode.isNullOrBlank() || !seriesName.isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Memby's paused-playback identity surface.
|
||||
*
|
||||
* The player owns only [visible] and immutable metadata. This component owns every visual
|
||||
* layer, remains non-interactive, and therefore cannot enter or alter the transport's TV
|
||||
* focus graph.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PauseMediaHero(
|
||||
metadata: PauseMediaHeroMetadata,
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
modifier = modifier,
|
||||
enter = fadeIn(tween(PauseHeroEnterMs)) +
|
||||
slideInVertically(tween(PauseHeroEnterMs)) { height -> height / 40 },
|
||||
exit = fadeOut(tween(PauseHeroExitMs)) +
|
||||
slideOutVertically(tween(PauseHeroExitMs)) { height -> height / 60 },
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
PauseHeroBackdrop(metadata.backdropUrl)
|
||||
PauseHeroContent(
|
||||
metadata = metadata,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 48.dp, top = 34.dp, end = 48.dp, bottom = 214.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PauseHeroBackdrop(backdropUrl: String?) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(backdropUrl, context) {
|
||||
backdropUrl?.let {
|
||||
ImageRequest.Builder(context)
|
||||
.data(it)
|
||||
.size(1280, 720)
|
||||
.allowHardware(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.74f))) {
|
||||
if (request != null) {
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.CenterEnd,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxWidth(0.74f)
|
||||
.fillMaxHeight()
|
||||
.cinematicBackdropPullBack(backdropUrl),
|
||||
)
|
||||
}
|
||||
// These are the same layered directions as the Home Metadata Hero: a firm text
|
||||
// field at the start, a controlled reveal through the artwork, and a dark landing
|
||||
// beneath the transport. The frozen video remains present only as a subdued base.
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.98f),
|
||||
0.34f to MembySurface.copy(alpha = 0.92f),
|
||||
0.56f to MembySurface.copy(alpha = 0.56f),
|
||||
0.78f to Color.Black.copy(alpha = 0.28f),
|
||||
1f to Color.Black.copy(alpha = 0.18f),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.24f),
|
||||
0.55f to Color.Transparent,
|
||||
0.78f to Color.Black.copy(alpha = 0.34f),
|
||||
1f to Color.Black.copy(alpha = 0.88f),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PauseHeroContent(
|
||||
metadata: PauseMediaHeroMetadata,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier, verticalArrangement = Arrangement.Top) {
|
||||
Text(
|
||||
text = stringResource(R.string.player_paused),
|
||||
color = MembyAccentBright,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.18.em,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PauseHeroTitle(metadata)
|
||||
if (metadata.isEpisode) {
|
||||
pauseHeroEpisodeLabel(metadata)?.let { label ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 23.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
PauseHeroSummary(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PauseHeroTitle(metadata: PauseMediaHeroMetadata) {
|
||||
val heading = metadata.seriesName?.takeIf { metadata.isEpisode && it.isNotBlank() }
|
||||
?: metadata.title.takeIf(String::isNotBlank)
|
||||
?: stringResource(R.string.player_now_playing)
|
||||
val logo = metadata.logoUrl?.takeIf { !useTextTitleForLogo(it) }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(0.52f).heightIn(max = 76.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (logo != null) {
|
||||
TitleLogoImage(
|
||||
logoUrl = logo,
|
||||
contentDescription = heading,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier.width(340.dp).height(76.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = heading,
|
||||
color = Color.White,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 38.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PauseHeroSummary(metadata: PauseMediaHeroMetadata) {
|
||||
val synopsis = metadata.overview.ifBlank {
|
||||
stringResource(R.string.player_pause_overview_fallback)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.64f),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
metadata.primaryArtworkUrl?.let { artwork ->
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = if (metadata.isEpisode) {
|
||||
Modifier.width(218.dp).aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.background(MembySurface)
|
||||
} else {
|
||||
Modifier.size(width = 116.dp, height = 174.dp)
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.background(MembySurface)
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = synopsis,
|
||||
color = MembyMutedText,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
maxLines = if (metadata.isEpisode) 5 else 7,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
|
||||
Text(
|
||||
text = stringResource(R.string.player_pause_resume_hint),
|
||||
color = MembyOnSurface.copy(alpha = 0.76f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun pauseHeroEpisodeLabel(metadata: PauseMediaHeroMetadata): String? {
|
||||
if (!metadata.isEpisode) return null
|
||||
val code = metadata.episodeCode?.trim().orEmpty()
|
||||
val spacedCode = Regex("^S(\\d{1,2})E(\\d{1,3})$", RegexOption.IGNORE_CASE)
|
||||
.matchEntire(code)
|
||||
?.destructured
|
||||
?.let { (season, episode) -> "S${season.padStart(2, '0')} E${episode.padStart(2, '0')}" }
|
||||
?: code.takeIf(String::isNotBlank)
|
||||
val episodeTitle = pauseHeroEpisodeTitle(metadata.title, metadata.seriesName)
|
||||
return listOfNotNull(spacedCode, episodeTitle).joinToString(" • ").takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
private fun pauseHeroEpisodeTitle(title: String, seriesName: String?): String? {
|
||||
var episodeTitle = title.trim()
|
||||
val series = seriesName?.trim().orEmpty()
|
||||
if (series.isNotEmpty()) {
|
||||
listOf(" – ", " — ", " - ").firstOrNull { separator ->
|
||||
episodeTitle.startsWith(series + separator, ignoreCase = true)
|
||||
}?.let { separator -> episodeTitle = episodeTitle.drop(series.length + separator.length).trim() }
|
||||
}
|
||||
return episodeTitle.takeIf { it.isNotBlank() && !it.equals(series, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private const val PauseHeroEnterMs = 180
|
||||
private const val PauseHeroExitMs = 120
|
||||
@@ -29,8 +29,10 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -243,6 +245,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var configuredPrerollDurationMs = DEFAULT_PREROLL_DURATION_MS
|
||||
private var pausePosterUrl: String? = null
|
||||
private var pauseOverlay: View? = null
|
||||
private val pauseHeroVisible = mutableStateOf(false)
|
||||
private val pauseHeroMetadata = mutableStateOf(PauseMediaHeroMetadata())
|
||||
private var nowPlayingGroup: View? = null
|
||||
private var playbackIdentityView: View? = null
|
||||
private var playbackIdentityHideJob: Job? = null
|
||||
@@ -1335,7 +1339,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
updatePauseHeroMetadata()
|
||||
}
|
||||
|
||||
private fun startPreroll() {
|
||||
@@ -2358,7 +2362,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
*/
|
||||
private fun showPlaybackIdentity() {
|
||||
val identity = playbackIdentityView ?: return
|
||||
val paused = pauseOverlay?.visibility == View.VISIBLE
|
||||
val paused = pauseHeroVisible.value
|
||||
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
|
||||
MembyDiagnostics.debug(
|
||||
"station_ident_withheld",
|
||||
@@ -2440,7 +2444,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
val slot = playerIdentitySlot(
|
||||
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
|
||||
transportVisible = transportVisible,
|
||||
paused = pauseOverlay?.visibility == View.VISIBLE,
|
||||
paused = pauseHeroVisible.value,
|
||||
)
|
||||
nowPlayingGroup?.visibility =
|
||||
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
|
||||
@@ -4328,19 +4332,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
|
||||
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
|
||||
pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
|
||||
val poster = pausePosterUrl
|
||||
if (poster.isNullOrBlank()) {
|
||||
visibility = View.GONE
|
||||
setImageDrawable(null)
|
||||
} else {
|
||||
visibility = View.VISIBLE
|
||||
load(poster) { crossfade(true) }
|
||||
}
|
||||
}
|
||||
updatePauseHeroMetadata()
|
||||
hidePlaybackError()
|
||||
showPlaybackLoading()
|
||||
if (playback != null) {
|
||||
@@ -4378,30 +4370,47 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun bindPauseOverlay(view: PlayerView) {
|
||||
pauseOverlay = view.findViewById(R.id.player_pause_overlay)
|
||||
pauseOverlay = view.findViewById<ComposeView>(R.id.player_pause_overlay).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
val visible by pauseHeroVisible
|
||||
val metadata by pauseHeroMetadata
|
||||
MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
|
||||
PauseMediaHero(
|
||||
metadata = metadata,
|
||||
visible = visible,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
|
||||
// The group is visible in the layout, so state it here too: nothing else runs before
|
||||
// the transport is first raised, and the ident's five seconds are inside that window.
|
||||
applyIdentityRegion()
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
|
||||
text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
|
||||
}
|
||||
pauseOverlay?.findViewById<ImageView>(R.id.player_pause_poster)?.apply {
|
||||
val poster = pausePosterUrl
|
||||
if (poster.isNullOrBlank()) {
|
||||
visibility = View.GONE
|
||||
} else {
|
||||
visibility = View.VISIBLE
|
||||
load(poster) { crossfade(true) }
|
||||
}
|
||||
}
|
||||
updatePauseHeroMetadata()
|
||||
}
|
||||
|
||||
private fun updatePauseHeroMetadata() {
|
||||
pauseHeroMetadata.value = PauseMediaHeroMetadata(
|
||||
title = playbackTitle,
|
||||
seriesName = playbackSeriesName,
|
||||
episodeCode = prerollEpisodeCode,
|
||||
overview = pauseOverview,
|
||||
logoUrl = logoUrl,
|
||||
backdropUrl = loadingBackdropUrl,
|
||||
primaryArtworkUrl = pausePosterUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updatePauseOverlay(playback: Player) {
|
||||
val paused = playbackStarted && !prerollActive &&
|
||||
playback.playbackState == Player.STATE_READY && !playback.isPlaying
|
||||
pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE
|
||||
// Metadata can change without rebuilding the activity (next episode, preview,
|
||||
// refreshed stream). Bind it at the state edge, before the enter transition starts,
|
||||
// so pausing never shows the outgoing programme for a frame.
|
||||
if (paused) updatePauseHeroMetadata()
|
||||
pauseHeroVisible.value = paused
|
||||
// Pausing during the ident hands the corner to the pause overlay, which carries the
|
||||
// poster, the title and the synopsis: an ident over the top of that is the same
|
||||
// programme announced twice, in two type sizes, in overlapping space.
|
||||
|
||||
@@ -1,80 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_pause_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<View
|
||||
android:layout_width="820dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_pause_scrim" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="760dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginBottom="62dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_pause_poster"
|
||||
android:layout_width="164dp"
|
||||
android:layout_height="246dp"
|
||||
android:background="@drawable/player_pause_poster_background"
|
||||
android:clipToOutline="true"
|
||||
android:contentDescription="@string/player_pause_poster"
|
||||
android:outlineProvider="background"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.18"
|
||||
android:text="@string/player_paused"
|
||||
android:textColor="#FF6BCB63"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_pause_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="34sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_pause_overview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:ellipsize="end"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:maxLines="5"
|
||||
android:textColor="#DDE7EBEE"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/player_pause_resume_hint"
|
||||
android:textColor="#B8FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
android:importantForAccessibility="no"
|
||||
android:visibility="visible" />
|
||||
|
||||
@@ -197,10 +197,11 @@ class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes the settings revision an operator push arrives as`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"preferencesRevision":12}""",
|
||||
"""{"maintenance":false,"preferencesRevision":12,"seriesStatusRevision":34}""",
|
||||
)
|
||||
|
||||
assertEquals(12L, status.preferencesRevision)
|
||||
assertEquals(34L, status.seriesStatusRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,13 +100,14 @@ class SeriesDetailsTest {
|
||||
name = "Series",
|
||||
type = "Series",
|
||||
productionYear = 2022,
|
||||
runTimeTicks = 31_200_000_000L,
|
||||
officialRating = "TV-MA",
|
||||
status = "Continuing",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("2022", "4 Seasons", "TV-MA", "Continuing"),
|
||||
heroFacts(series, seasonCount = 4),
|
||||
listOf("2022", "52m", "Continuing"),
|
||||
heroFacts(series),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,8 +121,8 @@ class SeriesDetailsTest {
|
||||
membyLifecycleText = "CANCELED",
|
||||
)
|
||||
|
||||
assertEquals("Cancelled", heroFacts(series).last())
|
||||
assertEquals("Ended", heroFacts(series.copy(membyLifecycleText = null)).last())
|
||||
assertEquals("Ended", heroFacts(series).last())
|
||||
assertEquals("Cancelled", heroFacts(series.copy(status = null)).last())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -141,7 +142,7 @@ class SeriesDetailsTest {
|
||||
status = "Continuing",
|
||||
)
|
||||
|
||||
assertEquals(listOf("2022"), heroFacts(unknownSeries))
|
||||
assertEquals(listOf("2022", "Upcoming"), heroFacts(unknownSeries))
|
||||
assertEquals(listOf("2022"), heroFacts(movie))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PauseMediaHeroTest {
|
||||
|
||||
@Test
|
||||
fun episodeLabelSeparatesSeasonEpisodeAndTitle() {
|
||||
assertEquals(
|
||||
"S04 E06 • The Dive",
|
||||
pauseHeroEpisodeLabel(
|
||||
PauseMediaHeroMetadata(
|
||||
title = "Northbound – The Dive",
|
||||
seriesName = "Northbound",
|
||||
episodeCode = "S04E06",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeLabelDoesNotRepeatSeriesName() {
|
||||
assertEquals(
|
||||
"S01 E02",
|
||||
pauseHeroEpisodeLabel(
|
||||
PauseMediaHeroMetadata(
|
||||
title = "Harbour Lights",
|
||||
seriesName = "Harbour Lights",
|
||||
episodeCode = "S01E02",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movieHasNoEpisodeLabel() {
|
||||
assertNull(pauseHeroEpisodeLabel(PauseMediaHeroMetadata(title = "Uproar")))
|
||||
}
|
||||
}
|
||||
+45
-15
@@ -7,8 +7,18 @@ import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
@@ -20,23 +30,43 @@ import org.robolectric.annotation.GraphicsMode
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class PlayerPauseOverlayScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `paused movie shows focused poster synopsis and resume controls`() {
|
||||
val (activity, root, controls) = playerSurface()
|
||||
fun `paused episode shows darkened hero artwork and synopsis`() {
|
||||
val preview = requireNotNull(previewArtwork())
|
||||
val artworkUrl = requireNotNull(javaClass.classLoader?.getResource("home_hero_preview_art.png"))
|
||||
.toExternalForm()
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Image(
|
||||
bitmap = preview.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
PauseMediaHero(
|
||||
metadata = PauseMediaHeroMetadata(
|
||||
title = "Northbound – The Last Horizon",
|
||||
seriesName = "Northbound",
|
||||
episodeCode = "S04E06",
|
||||
overview = "A cartographer follows a signal beyond the edge of the known world, " +
|
||||
"where an abandoned observatory may hold the way home.",
|
||||
backdropUrl = artworkUrl,
|
||||
primaryArtworkUrl = artworkUrl,
|
||||
),
|
||||
visible = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controls.findViewById<View>(R.id.player_pause_overlay).visibility = View.VISIBLE
|
||||
controls.findViewById<View>(R.id.player_now_playing_group).visibility = View.GONE
|
||||
controls.findViewById<TextView>(R.id.player_pause_title).text = "The Last Horizon"
|
||||
controls.findViewById<TextView>(R.id.player_pause_overview).text =
|
||||
"A cartographer follows a signal beyond the edge of the known world, " +
|
||||
"where an abandoned observatory may hold the way home."
|
||||
controls.findViewById<ImageView>(R.id.player_pause_poster).setImageBitmap(previewArtwork())
|
||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_position).text = "42:18"
|
||||
controls.findViewById<TextView>(androidx.media3.ui.R.id.exo_duration).text = "1:54:02"
|
||||
controls.findViewById<TextView>(R.id.player_remaining).text = "1h 12m left"
|
||||
controls.findViewById<TextView>(R.id.player_finish_time).text = "Ends at 10:14 PM"
|
||||
|
||||
root.captureRoboImage("build/screenshots/player-pause-overlay/player-paused-movie-overlay.png")
|
||||
compose.onRoot().captureRoboImage(
|
||||
"build/screenshots/player-pause-overlay/player-paused-episode-hero.png",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user