This commit is contained in:
ponzischeme89
2026-08-23 13:20:54 +12:00
parent 766cea2199
commit 89ebe21201
35 changed files with 1000 additions and 195 deletions
@@ -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")))
}
}
@@ -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