This commit is contained in:
ponzischeme89
2026-08-23 14:16:03 +12:00
parent 89ebe21201
commit 63c3df11b2
23 changed files with 789 additions and 758 deletions
@@ -3,22 +3,40 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
internal data class ResolvedCast(
val members: List<EmbyPerson> = emptyList(),
val guests: List<EmbyPerson> = emptyList(),
)
/**
* Loads the cast Emby associates with an item.
* Loads the regular cast and any episode-only appearances Emby associates with an item.
*
* Episodes commonly omit `People`, while their parent series holds the canonical cast.
* Keep episode-specific credits when Emby supplies them, and only pay for the series
* request when the episode has no cast of its own.
* An episode's `People` describes that episode, while its parent series carries the stable
* cast. Read both for episodes so the player can keep the familiar cast row and place only
* episode-specific people in its quieter Guest Cast row. Older libraries which carry no
* series cast still get a useful primary row from the episode rather than an empty panel.
*/
internal suspend fun resolveCast(
itemId: String,
loadItem: suspend (String) -> BaseItem,
): List<EmbyPerson> {
): ResolvedCast {
val item = loadItem(itemId)
if (item.cast.isNotEmpty()) return item.cast
if (!item.isEpisode) return ResolvedCast(members = item.cast)
val seriesId = item.seriesId
?.takeIf(String::isNotBlank)
?.takeUnless { it == item.id }
return if (item.isEpisode && seriesId != null) loadItem(seriesId).cast else emptyList()
?: return ResolvedCast(members = item.cast)
val seriesCast = runCatching { loadItem(seriesId).cast }.getOrDefault(emptyList())
if (seriesCast.isEmpty()) return ResolvedCast(members = item.cast)
val regularKeys = seriesCast.mapTo(mutableSetOf(), EmbyPerson::castIdentity)
return ResolvedCast(
members = seriesCast,
guests = item.cast.filterNot { it.castIdentity() in regularKeys },
)
}
private fun EmbyPerson.castIdentity(): String =
id.trim().takeIf(String::isNotBlank)?.let { "id:$it" }
?: "name:${name.trim().lowercase()}"
@@ -10,6 +10,7 @@ import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.MembyViewerRequest
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayMagicPick
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvent
@@ -98,20 +99,24 @@ data class NextEpisode(
)
/**
* One film Magic drew, flattened to what the player needs to launch it. Deliberately not a
* [Playable]: nothing has been negotiated yet, and resolving a stream for a title the viewer
* has not yet been shown would create a playback session for something nobody watched.
* One film Magic drew. [item] remains Emby's complete item model so selecting it can enter
* the same playback-request and artwork pipeline as a film selected from a shelf.
*
* Deliberately not a [Playable]: nothing has been negotiated yet, and resolving a stream for
* a title the viewer has not yet been shown would create a playback session for something
* nobody watched.
*/
data class MagicPick(
val itemId: String,
val title: String,
val itemType: String = "",
val overview: String? = null,
val runtimeMs: Long = 0L,
val logoUrl: String? = null,
val backdropUrl: String? = null,
val item: BaseItem,
val reasons: List<String> = emptyList(),
)
) {
companion object {
internal fun fromGateway(response: GatewayMagicPick): MagicPick? {
val item = response.item?.takeIf { it.id.isNotBlank() } ?: return null
return MagicPick(item = item, reasons = response.reasons)
}
}
}
/** A resolved, directly playable stream. */
data class Playable(
@@ -2709,17 +2714,7 @@ class EmbyRepository internal constructor(
com.ponzischeme89.memby.data.model.GatewayMagicRequest(excludeIds = excludeIds),
)
}.getOrNull() ?: return null
val item = response.item?.takeIf { it.id.isNotBlank() } ?: return null
return MagicPick(
itemId = item.id,
title = item.name.orEmpty().ifBlank { "Something to watch" },
itemType = item.type.orEmpty(),
overview = item.overview,
runtimeMs = ((item.runTimeTicks ?: 0L) / 10_000L).coerceAtLeast(0L),
logoUrl = logoUrl(item),
backdropUrl = backdropUrl(item),
reasons = response.reasons,
)
return MagicPick.fromGateway(response)
}
suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? {
@@ -126,8 +126,17 @@ fun EpisodeDetailsOverlay(
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
// The route may carry only the lightweight Continue Watching/Next Up record. The
// episode catalogue is already the shared full-metadata load for this page, so use its
// matching record as soon as it arrives instead of leaving the synopsis dependent on
// how complete the navigation object happened to be. The route item remains the owner
// of live user data, including its resume position.
val detailedItem = remember(item, episodes) {
episodeItemWithCatalogueOverview(item, episodes)
}
EpisodeDetailContent(
item = item,
item = detailedItem,
episodes = episodes,
loadFailed = loadFailed,
onPlay = onPlay,
@@ -141,6 +150,20 @@ fun EpisodeDetailsOverlay(
)
}
/** Fills a lightweight episode route from the full catalogue without replacing route state. */
internal fun episodeItemWithCatalogueOverview(
item: BaseItem,
episodes: List<BaseItem>?,
): BaseItem {
if (!item.overview.isNullOrBlank()) return item
val overview = episodes
?.firstOrNull { it.id == item.id }
?.overview
?.takeIf(String::isNotBlank)
?: return item
return item.copy(overview = overview)
}
/** [episodes] is null while the series' episode list is still coming. */
@Composable
internal fun EpisodeDetailContent(
@@ -697,33 +697,13 @@ internal fun HomeScreen(
val launched = runCatching {
playbackLauncher.launch(
PlayerActivity.intent(
context = context,
itemId = playable.itemId,
url = playable.url,
title = playable.title,
resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
seriesName = playable.seriesName,
overview = playable.overview ?: item.overview,
episodeCode = playable.episodeCode,
runtimeMs = playable.runtimeMs,
prerollEnabled = playable.prerollEnabled,
prerollDurationMs = playable.prerollDurationMs,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
subtitles = playable.subtitles,
subtitlesEnabled = playable.subtitlesEnabled,
selectedSubtitleId = playable.selectedSubtitleId,
subtitleDownloadAvailable = playable.subtitleDownloadAvailable,
trickplayAvailable = playable.trickplayAvailable,
skipIntroAvailable = playable.skipIntroAvailable,
endCreditsAvailable = playable.endCreditsAvailable,
mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId,
playMethod = playable.playMethod,
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
context = context,
playable = playable,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
posterUrl = repo.primaryUrl(item, maxWidth = 500),
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
)
}
@@ -1,22 +1,63 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
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.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.membyTypeface
import com.ponzischeme89.memby.ui.detail.detailCardFocus
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
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.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
data class CastMember(
val name: String,
val role: String? = null,
@@ -42,256 +83,350 @@ data class CastPersonPanel(
val loaded: Boolean = false,
)
/**
* What the panel is showing.
*
* [loaded] is separate from an empty [members] list because "still fetching" and "this
* title has no cast recorded" are different things to be told, and a spinner that never
* resolves is the worse of the two to leave on screen.
*/
/** Plain state keeps the player as owner and makes this surface previewable and testable. */
data class CastPanelState(
val title: String = "",
val members: List<CastMember> = emptyList(),
val guests: List<CastMember> = emptyList(),
val loaded: Boolean = false,
val selectedPerson: CastPersonPanel? = null,
)
/**
* Fills the cast overlay from a plain state.
* The interactive lower section of the paused hero.
*
* Like [bindSubtitleMenu], it lives apart from [PlayerActivity] so `CastPanelScreenshotTest`
* can render the real panel — these cards, these colours, this layout — with no player, no
* Emby server and no network. [loadImage] is injected for the same reason: artwork is the
* one thing a screenshot test cannot fetch, and passing it in means the test renders the
* initials fallback rather than a blank where every portrait should be.
* It deliberately owns no full-screen scrim or programme heading: the paused hero already
* supplies both. This is one quiet, rounded section in the hero's reading-safe area, and the
* only secondary heading is Guest Cast when there are episode-only appearances to name.
*/
fun bindCastPanel(
overlay: View,
@Composable
internal fun CastPanel(
state: CastPanelState,
loadImage: (ImageView, String) -> Unit = { _, _ -> },
onMemberClick: (CastMember) -> Unit = {},
visible: Boolean,
onMemberClick: (CastMember) -> Unit,
modifier: Modifier = Modifier,
) {
val context = overlay.context
val firstMember = remember { FocusRequester() }
val firstGuest = remember { FocusRequester() }
val firstFilmography = remember { FocusRequester() }
val selected = state.selectedPerson
overlay.findViewById<TextView>(R.id.player_cast_eyebrow).text = context.getString(
if (selected == null) R.string.player_cast_eyebrow else R.string.player_cast_profile_eyebrow,
)
overlay.findViewById<TextView>(R.id.player_cast_title).apply {
text = selected?.name ?: state.title
isVisible = !text.isNullOrBlank()
}
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
text = when {
selected != null && !selected.loaded -> context.getString(R.string.player_cast_profile_loading)
!state.loaded -> context.getString(R.string.player_cast_loading)
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
else -> ""
}
isVisible = !text.isNullOrEmpty()
}
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
people.removeAllViews()
state.members.forEach { member ->
people.addView(castCard(context, member, loadImage, onMemberClick))
}
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible =
selected == null && state.members.isNotEmpty()
overlay.findViewById<LinearLayout>(R.id.player_cast_profile).apply {
isVisible = selected?.loaded == true
removeAllViews()
selected?.takeIf { it.loaded }?.let { bindPersonProfile(this, it, loadImage) }
}
overlay.findViewById<TextView>(R.id.player_cast_back_hint).setText(
if (selected == null) R.string.player_back_to_close else R.string.player_cast_back_to_cast,
)
}
private fun castCard(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
onClick: (CastMember) -> Unit,
): View {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
isFocusable = true
isClickable = true
setOnClickListener { onClick(member) }
clipChildren = false
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = dp(18)
}
setOnFocusChangeListener { view, focused ->
view.animate()
.scaleX(if (focused) 1.06f else 1f)
.scaleY(if (focused) 1.06f else 1f)
.setDuration(120L)
.start()
LaunchedEffect(
visible,
state.loaded,
state.members.firstOrNull()?.id,
state.guests.firstOrNull()?.id,
selected?.id,
selected?.loaded,
) {
if (!visible) return@LaunchedEffect
when {
selected?.loaded == true && selected.filmography.isNotEmpty() ->
firstFilmography.requestFocus()
selected == null && state.members.isNotEmpty() -> firstMember.requestFocus()
selected == null && state.guests.isNotEmpty() -> firstGuest.requestFocus()
}
}
addView(castPortrait(context, member, loadImage, ::dp))
addView(LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
setPadding(0, dp(9), 0, 0)
addView(TextView(context).apply {
typeface = context.membyTypeface(Typeface.BOLD)
text = member.name
setTextColor(Color.WHITE)
textSize = 14f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
if (!member.deathDate.isNullOrBlank()) {
addView(ImageView(context).apply {
setImageResource(R.drawable.ic_deceased)
contentDescription = context.getString(R.string.player_cast_deceased)
setPadding(dp(4), 0, 0, 0)
}, LinearLayout.LayoutParams(dp(18), dp(16)))
val shape = RoundedCornerShape(MembyPanelCorner)
Box(
modifier = modifier
.clip(shape)
.background(MembySurfaceRaised.copy(alpha = 0.94f))
.border(1.dp, MembyHairline, shape)
.padding(horizontal = 18.dp, vertical = 14.dp),
) {
Crossfade(
targetState = selected,
animationSpec = tween(CastContentFadeMs),
label = "paused-cast-content",
) { person ->
if (person == null) {
CastRows(
state = state,
firstMember = firstMember,
firstGuest = firstGuest,
onMemberClick = onMemberClick,
)
} else {
CastProfile(person = person, firstFilmography = firstFilmography)
}
})
member.role?.takeIf(String::isNotBlank)?.let { role ->
addView(
TextView(context).apply {
typeface = context.membyTypeface()
text = role
setTextColor(Color.rgb(158, 168, 178))
textSize = 11f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(2), 0, 0)
},
}
}
}
@Composable
private fun CastRows(
state: CastPanelState,
firstMember: FocusRequester,
firstGuest: FocusRequester,
onMemberClick: (CastMember) -> Unit,
) {
when {
!state.loaded -> CastStatus(stringResource(R.string.player_cast_loading))
state.members.isEmpty() && state.guests.isEmpty() ->
CastStatus(stringResource(R.string.player_cast_empty))
else -> Column(Modifier.fillMaxSize()) {
if (state.members.isNotEmpty()) {
CastRow(
members = state.members,
firstFocus = firstMember,
onMemberClick = onMemberClick,
cardWidth = 104.dp,
portraitHeight = 112.dp,
modifier = Modifier.weight(1f),
)
}
if (state.guests.isNotEmpty()) {
if (state.members.isNotEmpty()) Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.player_guest_cast),
color = MembyAccentBright,
fontSize = 11.sp,
lineHeight = 14.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
)
Spacer(Modifier.height(5.dp))
CastRow(
members = state.guests,
firstFocus = firstGuest,
onMemberClick = onMemberClick,
cardWidth = 176.dp,
portraitHeight = 58.dp,
compact = true,
modifier = Modifier.height(64.dp),
)
}
}
}
}
@Composable
private fun CastStatus(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) {
Text(text = text, color = MembyMutedText, fontSize = 14.sp)
}
}
@Composable
private fun CastRow(
members: List<CastMember>,
firstFocus: FocusRequester,
onMemberClick: (CastMember) -> Unit,
cardWidth: Dp,
portraitHeight: Dp,
modifier: Modifier = Modifier,
compact: Boolean = false,
) {
LazyRow(
modifier = modifier.fillMaxWidth().focusGroup(),
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
itemsIndexed(
items = members,
key = { index, member -> member.id.ifBlank { "$index:${member.name}" } },
) { index, member ->
CastMemberCard(
member = member,
onClick = { onMemberClick(member) },
cardWidth = cardWidth,
portraitHeight = portraitHeight,
compact = compact,
modifier = if (index == 0) Modifier.focusRequester(firstFocus) else Modifier,
)
}
}
}
private fun bindPersonProfile(
container: LinearLayout,
person: CastPersonPanel,
loadImage: (ImageView, String) -> Unit,
@Composable
private fun CastMemberCard(
member: CastMember,
onClick: () -> Unit,
cardWidth: Dp,
portraitHeight: Dp,
compact: Boolean,
modifier: Modifier = Modifier,
) {
val context = container.context
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
val life = personLifeDates(person.birthDate, person.deathDate)
if (life.isNotBlank() || !person.role.isNullOrBlank()) {
container.addView(TextView(context).apply {
typeface = context.membyTypeface()
text = listOfNotNull(
person.role?.takeIf(String::isNotBlank),
life.takeIf(String::isNotBlank),
).joinToString(" · ")
setTextColor(Color.rgb(185, 193, 200))
textSize = 13f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
})
var focused by remember { mutableStateOf(false) }
if (compact) {
Row(
modifier = modifier
.width(cardWidth)
.fillMaxHeight()
.detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.testTag("guest-cast-${member.name}"),
verticalAlignment = Alignment.CenterVertically,
) {
CastPortrait(member, Modifier.size(portraitHeight), focused)
Column(Modifier.padding(start = 9.dp).weight(1f)) {
Text(
text = listOfNotNull(
member.displayName.takeIf(String::isNotBlank),
member.role?.takeIf(String::isNotBlank),
).joinToString(FactSeparator),
color = MembyOnSurface,
fontSize = 12.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
} else {
Column(
modifier = modifier
.width(cardWidth)
.detailCardFocus(focused)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.testTag("cast-member-${member.name}"),
) {
CastPortrait(member, Modifier.fillMaxWidth().height(portraitHeight), focused)
Text(
text = member.displayName,
color = MembyOnSurface,
fontSize = 12.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = member.role?.takeIf(String::isNotBlank) ?: " ",
color = MembyMutedText,
fontSize = 10.sp,
lineHeight = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
container.addView(TextView(context).apply {
typeface = context.membyTypeface()
text = person.overview?.takeIf(String::isNotBlank)
?: context.getString(R.string.player_cast_biography_empty)
setTextColor(Color.WHITE)
textSize = 14f
maxLines = 3
ellipsize = TextUtils.TruncateAt.END
setLineSpacing(0f, 1.08f)
setPadding(0, dp(7), 0, 0)
})
container.addView(TextView(context).apply {
typeface = context.membyTypeface(Typeface.BOLD)
text = context.getString(R.string.player_cast_filmography)
setTextColor(Color.rgb(82, 181, 75))
textSize = 11f
letterSpacing = 0.12f
setPadding(0, dp(13), 0, dp(5))
})
if (person.filmography.isEmpty()) {
container.addView(TextView(context).apply {
typeface = context.membyTypeface()
text = context.getString(R.string.player_cast_filmography_empty)
setTextColor(Color.rgb(185, 193, 200))
textSize = 13f
})
}
private val CastMember.displayName: String
get() = if (deathDate.isNullOrBlank()) name else "$name"
@Composable
private fun CastPortrait(member: CastMember, modifier: Modifier, focused: Boolean) {
val shape = RoundedCornerShape(MembyCardCorner)
Box(
modifier = modifier
.clip(shape)
.background(MembyControlSurface)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White else MembyHairline,
shape = shape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = castInitials(member.name),
color = MembyQuietText,
fontSize = 25.sp,
fontWeight = FontWeight.Bold,
)
member.imageUrl?.takeIf(String::isNotBlank)?.let { portrait ->
AsyncImage(
model = portrait,
contentDescription = member.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun CastProfile(person: CastPersonPanel, firstFilmography: FocusRequester) {
if (!person.loaded) {
CastStatus(stringResource(R.string.player_cast_profile_loading))
return
}
container.addView(HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
clipChildren = false
addView(LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
clipChildren = false
person.filmography.forEach { credit ->
addView(filmographyCard(context, credit, loadImage, ::dp))
Column(Modifier.fillMaxSize()) {
Text(
text = person.name,
color = MembyOnSurface,
fontSize = 20.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.SemiBold,
)
val details = listOfNotNull(
person.role?.takeIf(String::isNotBlank),
personLifeDates(person.birthDate, person.deathDate).takeIf(String::isNotBlank),
).joinToString(" · ")
if (details.isNotBlank()) {
Text(text = details, color = MembyMutedText, fontSize = 12.sp, maxLines = 1)
}
Text(
text = person.overview?.takeIf(String::isNotBlank)
?: stringResource(R.string.player_cast_biography_empty),
color = MembyMutedText,
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
if (person.filmography.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LazyRow(
modifier = Modifier.fillMaxWidth().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 3.dp, bottom = 3.dp),
) {
itemsIndexed(person.filmography) { index, credit ->
FilmographyCard(
credit = credit,
modifier = if (index == 0) Modifier.focusRequester(firstFilmography) else Modifier,
)
}
}
})
})
}
}
}
private fun filmographyCard(
context: Context,
credit: FilmographyCredit,
loadImage: (ImageView, String) -> Unit,
dp: (Int) -> Int,
): View = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
isFocusable = true
isClickable = true
setOnClickListener { }
background = context.getDrawable(R.drawable.player_cast_portrait_background)
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame)
setOnFocusChangeListener { view, focused ->
view.animate()
.scaleX(if (focused) 1.03f else 1f)
.scaleY(if (focused) 1.03f else 1f)
.setDuration(120L)
.start()
}
setPadding(dp(5), dp(5), dp(9), dp(5))
layoutParams = LinearLayout.LayoutParams(dp(194), dp(68)).apply { marginEnd = dp(10) }
addView(FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(42), dp(58)).apply { marginEnd = dp(9) }
background = context.getDrawable(R.drawable.player_cast_portrait_background)
clipToOutline = true
addView(ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@Composable
private fun FilmographyCard(credit: FilmographyCredit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier = modifier
.width(180.dp)
.height(62.dp)
.detailCardFocus(focused)
.clip(shape)
.background(MembyControlSurface)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyHairline, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = {}),
verticalAlignment = Alignment.CenterVertically,
) {
if (credit.imageUrl != null) {
AsyncImage(
model = credit.imageUrl,
contentDescription = credit.title,
contentScale = ContentScale.Crop,
modifier = Modifier.width(44.dp).fillMaxHeight(),
)
scaleType = ImageView.ScaleType.CENTER_CROP
contentDescription = credit.title
credit.imageUrl?.takeIf(String::isNotBlank)?.let { loadImage(this, it) }
})
})
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
addView(TextView(context).apply {
typeface = context.membyTypeface(Typeface.BOLD)
text = credit.title
setTextColor(Color.WHITE)
textSize = 12f
maxLines = 2
ellipsize = TextUtils.TruncateAt.END
})
credit.year?.let { year ->
addView(TextView(context).apply {
typeface = context.membyTypeface()
text = year.toString()
setTextColor(Color.rgb(158, 168, 178))
textSize = 11f
})
}
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
Column(Modifier.padding(horizontal = 9.dp).weight(1f)) {
Text(
text = credit.title,
color = MembyOnSurface,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
credit.year?.let { Text(it.toString(), color = MembyMutedText, fontSize = 10.sp) }
}
}
}
internal fun personLifeDates(birthDate: String?, deathDate: String?): String {
@@ -315,59 +450,6 @@ private fun formatPersonDate(value: String?): String {
return "${day.toIntOrNull() ?: day} $monthName $year"
}
/**
* The portrait, with the person's initials underneath it.
*
* Emby has no photo for a good part of a typical cast, and a row of identical grey
* rectangles tells a viewer nothing about which name is which. The initials sit *behind*
* the image rather than replacing it, so nothing has to decide in advance whether the
* artwork will arrive — when it does, it simply covers them.
*/
private fun castPortrait(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
dp: (Int) -> Int,
): View = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(132), dp(176))
background = context.getDrawable(R.drawable.player_cast_portrait_background)
// The ring is drawn over the artwork, and the frame follows the card's focus rather
// than its own — the card is what takes focus, the portrait is never focusable itself.
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame)
isDuplicateParentStateEnabled = true
clipToOutline = true
addView(
TextView(context).apply {
typeface = context.membyTypeface()
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
text = castInitials(member.name)
setTextColor(Color.rgb(126, 138, 148))
textSize = 34f
gravity = Gravity.CENTER
},
)
addView(
ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
scaleType = ImageView.ScaleType.CENTER_CROP
contentDescription = member.name
member.imageUrl?.takeIf(String::isNotBlank)?.let { loadImage(this, it) }
},
)
}
/**
* Up to two initials from a name. Pure, so it can be tested without a view: the cases that
* matter are a mononym, a name with a middle name (the *last* initial is the useful one,
* not the second) and a blank, which yields nothing rather than a stray character.
*/
internal fun castInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter(String::isNotBlank)
return when (parts.size) {
@@ -376,3 +458,5 @@ internal fun castInitials(name: String): String {
else -> (parts.first().take(1) + parts.last().take(1)).uppercase()
}
}
private const val CastContentFadeMs = 120
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -75,10 +76,13 @@ internal data class PauseMediaHeroMetadata(
internal fun PauseMediaHero(
metadata: PauseMediaHeroMetadata,
visible: Boolean,
castVisible: Boolean = false,
castState: CastPanelState = CastPanelState(),
onCastMemberClick: (CastMember) -> Unit = {},
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
visible = visible || castVisible,
modifier = modifier,
enter = fadeIn(tween(PauseHeroEnterMs)) +
slideInVertically(tween(PauseHeroEnterMs)) { height -> height / 40 },
@@ -89,9 +93,17 @@ internal fun PauseMediaHero(
PauseHeroBackdrop(metadata.backdropUrl)
PauseHeroContent(
metadata = metadata,
castVisible = castVisible,
castState = castState,
onCastMemberClick = onCastMemberClick,
modifier = Modifier
.fillMaxSize()
.padding(start = 48.dp, top = 34.dp, end = 48.dp, bottom = 214.dp),
.padding(
start = 48.dp,
top = 34.dp,
end = 48.dp,
bottom = if (castVisible) 28.dp else 214.dp,
),
)
}
}
@@ -154,6 +166,9 @@ private fun PauseHeroBackdrop(backdropUrl: String?) {
@Composable
private fun PauseHeroContent(
metadata: PauseMediaHeroMetadata,
castVisible: Boolean,
castState: CastPanelState,
onCastMemberClick: (CastMember) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier, verticalArrangement = Arrangement.Top) {
@@ -181,8 +196,23 @@ private fun PauseHeroContent(
)
}
}
Spacer(Modifier.height(18.dp))
PauseHeroSummary(metadata)
Spacer(Modifier.height(if (castVisible) 12.dp else 18.dp))
Crossfade(
targetState = castVisible,
animationSpec = tween(PauseHeroSectionFadeMs),
label = "paused-hero-section",
) { showingCast ->
if (showingCast) {
CastPanel(
state = castState,
visible = true,
onMemberClick = onCastMemberClick,
modifier = Modifier.fillMaxWidth().height(292.dp),
)
} else {
PauseHeroSummary(metadata)
}
}
}
}
@@ -290,3 +320,4 @@ private fun pauseHeroEpisodeTitle(title: String, seriesName: String?): String? {
private const val PauseHeroEnterMs = 180
private const val PauseHeroExitMs = 120
private const val PauseHeroSectionFadeMs = 120
@@ -315,13 +315,16 @@ class PlayerActivity : ComponentActivity() {
/** Shown once a manually selected subtitle survives a media-item reload. */
private var pendingSubtitleConfirmation: String? = null
private var subtitleOverlay: View? = null
private var castOverlay: View? = null
private var castJob: Job? = null
private var castPeople: List<EmbyPerson> = emptyList()
private var castGuests: List<EmbyPerson> = emptyList()
private var castLoaded = false
private var castProfiles: Map<String, BaseItem> = emptyMap()
private var selectedCastPerson: CastPersonPanel? = null
private var castPersonJob: Job? = null
private val castPanelVisible = mutableStateOf(false)
private val castPanelState = mutableStateOf(CastPanelState())
private var transportPanel: View? = null
private var prerollView: View? = null
private var localPrerollPlayer: ExoPlayer? = null
private var localPrerollView: PlayerView? = null
@@ -976,7 +979,6 @@ class PlayerActivity : ComponentActivity() {
logoUrl = logoUrl,
)
setUpSubtitleOverlay()
setUpCastOverlay()
setUpNextUpBanner()
setUpSkipIntro()
setUpEndCredits()
@@ -2706,7 +2708,7 @@ class PlayerActivity : ComponentActivity() {
playback.duration > 0L &&
playback.duration != C.TIME_UNSET &&
playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
@@ -3042,7 +3044,7 @@ class PlayerActivity : ComponentActivity() {
playbackStarted &&
!prerollActive &&
playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
@@ -3415,8 +3417,21 @@ class PlayerActivity : ComponentActivity() {
.show()
return@launch
}
magicOffered += pick.itemId
val selectedItem = pick.item
magicOffered += selectedItem.id
if (magicOffered.size > MAGIC_MEMORY) magicOffered.removeAt(0)
val repository = ServiceLocator.repository
val playable = runCatching {
repository.resolvePlayableForLaunch(repository.playbackRequest(selectedItem))
}.getOrElse {
hidePlaybackLoading()
Toast.makeText(
this@PlayerActivity,
R.string.playback_server_unreachable,
Toast.LENGTH_SHORT,
).show()
return@launch
}
// The whole sequence, in the order it happened: the film that was on ends, the
// recommendation is recorded as chosen, and the request for the new one carries
// magic_movie as its entry point — which is what makes the console cut a fresh
@@ -3425,21 +3440,21 @@ class PlayerActivity : ComponentActivity() {
recordPlaybackFinishedJourney(completed = false)
PlaybackJourney.magicSelected(
sink = JourneyTracker,
itemId = pick.itemId,
itemName = pick.title,
itemType = pick.itemType,
itemId = selectedItem.id,
itemName = selectedItem.name,
itemType = selectedItem.type,
)
PlaybackJourney.requested(
sink = JourneyTracker,
entryPoint = PlaybackEntryPoint.MAGIC_MOVIE,
screen = "player",
itemId = pick.itemId,
itemName = pick.title,
itemType = pick.itemType,
itemId = selectedItem.id,
itemName = selectedItem.name,
itemType = selectedItem.type,
)
Toast.makeText(
this@PlayerActivity,
getString(R.string.player_magic_selected, pick.title),
getString(R.string.player_magic_selected, selectedItem.name),
Toast.LENGTH_SHORT,
).show()
// A film is a new subject, not the next step of this one, so it goes through the
@@ -3453,16 +3468,11 @@ class PlayerActivity : ComponentActivity() {
// nothing playing.
startActivity(
intent(
this@PlayerActivity,
PlaybackRequest(
itemId = pick.itemId,
itemType = pick.itemType,
title = pick.title,
overview = pick.overview,
runtimeMs = pick.runtimeMs,
logoUrl = pick.logoUrl,
),
backdropUrl = pick.backdropUrl,
context = this@PlayerActivity,
playable = playable,
posterUrl = repository.primaryUrl(selectedItem, maxWidth = 500),
backdropUrl = repository.backdropUrl(selectedItem, maxWidth = 1920)
?: repository.primaryUrl(selectedItem, maxWidth = 1920),
journeySource = PlaybackEntryPoint.MAGIC_MOVIE.id,
),
)
@@ -4375,15 +4385,21 @@ class PlayerActivity : ComponentActivity() {
setContent {
val visible by pauseHeroVisible
val metadata by pauseHeroMetadata
val castVisible by castPanelVisible
val castState by castPanelState
MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
PauseMediaHero(
metadata = metadata,
visible = visible,
castVisible = castVisible,
castState = castState,
onCastMemberClick = ::showCastPerson,
modifier = Modifier.fillMaxSize(),
)
}
}
}
transportPanel = view.findViewById(R.id.player_transport_panel)
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.
@@ -4428,9 +4444,9 @@ class PlayerActivity : ComponentActivity() {
// activity before our UP handler gets a chance to hide the active surface.
if (event.action == KeyEvent.ACTION_UP) {
when {
castOverlay?.isVisible == true && selectedCastPerson != null ->
castPanelVisible.value && selectedCastPerson != null ->
showCastList()
castOverlay?.isVisible == true -> hideCastOverlay()
castPanelVisible.value -> hideCastOverlay()
// The drop-up's download half is a level of its own, so Back leaves it
// before it leaves the menu — one press per level, never two at once.
subtitleOverlay?.isVisible == true && subtitleDownloadExpanded ->
@@ -4494,7 +4510,7 @@ class PlayerActivity : ComponentActivity() {
!prerollActive &&
player != null &&
playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
// The pane's Play button holds focus while it is up, and the centre key is how a
@@ -4742,17 +4758,12 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun setUpCastOverlay() {
castOverlay = findViewById<View>(R.id.player_cast_overlay).apply {
setOnClickListener { hideCastOverlay() }
}
}
private fun loadCast() {
castJob?.cancel()
castPersonJob?.cancel()
castLoaded = false
castPeople = emptyList()
castGuests = emptyList()
castProfiles = emptyMap()
selectedCastPerson = null
val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run {
@@ -4763,15 +4774,17 @@ class PlayerActivity : ComponentActivity() {
castJob = lifecycleScope.launch {
val loaded = runCatching {
resolveCast(requestedItemId, ServiceLocator.repository::getItemDetails)
.take(MAX_CAST_MEMBERS)
}.getOrDefault(emptyList())
}.getOrDefault(com.ponzischeme89.memby.data.ResolvedCast())
val members = loaded.members.take(MAX_CAST_MEMBERS)
val guests = loaded.guests.take(MAX_GUEST_CAST_MEMBERS)
if (itemId == requestedItemId) {
castPeople = loaded
castPeople = members
castGuests = guests
castLoaded = true
bindCastOverlay()
}
val profiles = coroutineScope {
loaded.filter { it.id.isNotBlank() }.map { person ->
(members + guests).distinctBy(EmbyPerson::id).filter { it.id.isNotBlank() }.map { person ->
async {
runCatching { ServiceLocator.repository.getPersonDetails(person.id) }
.getOrNull()
@@ -4787,8 +4800,13 @@ class PlayerActivity : ComponentActivity() {
private fun showCastOverlay() {
if (playingTrailer) return
playerView?.hideController()
castOverlay?.visibility = View.VISIBLE
player?.pause()
updatePauseHeroMetadata()
playerView?.showController()
transportPanel?.visibility = View.GONE
pauseOverlay?.isFocusable = true
castPanelVisible.value = true
pauseOverlay?.requestFocus()
bindCastOverlay()
if (!castLoaded && castJob?.isActive != true) loadCast()
}
@@ -4796,8 +4814,14 @@ class PlayerActivity : ComponentActivity() {
private fun hideCastOverlay() {
castPersonJob?.cancel()
selectedCastPerson = null
castOverlay?.visibility = View.GONE
castPanelVisible.value = false
pauseOverlay?.isFocusable = false
bindCastOverlay()
transportPanel?.visibility = View.VISIBLE
playerView?.showController()
playerView?.findViewById<View>(R.id.player_cast)?.post {
playerView?.findViewById<View>(R.id.player_cast)?.requestFocus()
}
}
private fun showCastList() {
@@ -4852,43 +4876,22 @@ class PlayerActivity : ComponentActivity() {
}
private fun bindCastOverlay() {
val overlay = castOverlay ?: return
bindCastPanel(
overlay = overlay,
state = CastPanelState(
title = playbackTitle,
members = castPeople.map { person ->
val profile = castProfiles[person.id]
CastMember(
name = person.name,
role = person.role,
imageUrl = ServiceLocator.repository.personImageUrl(person, 320),
id = person.id,
deathDate = profile?.endDate,
)
},
loaded = castLoaded,
selectedPerson = selectedCastPerson,
),
// Coil is handed in rather than reached for inside the panel, which is what lets
// the screenshot test render the same cards without a network.
loadImage = { view, url -> view.load(url) },
onMemberClick = ::showCastPerson,
)
if (overlay.isVisible) {
if (selectedCastPerson == null) {
overlay.findViewById<LinearLayout>(R.id.player_cast_people)
.getChildAt(0)
?.requestFocus()
?: overlay.requestFocus()
} else {
overlay.findViewById<LinearLayout>(R.id.player_cast_profile)
.getFocusables(View.FOCUS_FORWARD)
.firstOrNull()
?.requestFocus()
?: overlay.requestFocus()
}
fun EmbyPerson.toCastMember(): CastMember {
val profile = castProfiles[id]
return CastMember(
name = name,
role = role,
imageUrl = ServiceLocator.repository.personImageUrl(this, 320),
id = id,
deathDate = profile?.endDate,
)
}
castPanelState.value = CastPanelState(
members = castPeople.map { it.toCastMember() },
guests = castGuests.map { it.toCastMember() },
loaded = castLoaded,
selectedPerson = selectedCastPerson,
)
}
@OptIn(UnstableApi::class)
@@ -5928,6 +5931,47 @@ class PlayerActivity : ComponentActivity() {
resumePositionMs: Long = 0L,
): Intent = intent(context, itemId = null, url = url, title = title, resumePositionMs = resumePositionMs)
/**
* The single mapping from the server-resolved playback model into a player launch.
* Manual selection and Magic both use it, so the server's pre-roll decision and the
* complete stream metadata cannot drift between entry points.
*/
fun intent(
context: Context,
playable: Playable,
posterUrl: String? = null,
backdropUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
): Intent = intent(
context = context,
itemId = playable.itemId,
url = playable.url,
title = playable.title,
resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
backdropUrl = backdropUrl,
seriesName = playable.seriesName,
overview = playable.overview,
episodeCode = playable.episodeCode,
runtimeMs = playable.runtimeMs,
prerollEnabled = playable.prerollEnabled,
prerollDurationMs = playable.prerollDurationMs,
posterUrl = posterUrl,
subtitles = playable.subtitles,
subtitlesEnabled = playable.subtitlesEnabled,
selectedSubtitleId = playable.selectedSubtitleId,
subtitleDownloadAvailable = playable.subtitleDownloadAvailable,
trickplayAvailable = playable.trickplayAvailable,
skipIntroAvailable = playable.skipIntroAvailable,
endCreditsAvailable = playable.endCreditsAvailable,
mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId,
playMethod = playable.playMethod,
requestStartedAtMs = requestStartedAtMs,
journeySource = journeySource,
)
fun intent(
context: Context,
itemId: String?,
@@ -6039,6 +6083,7 @@ class PlayerActivity : ComponentActivity() {
private const val PREROLL_FADE_MS = 180L
private const val PREROLL_TRANSITION_MS = 480L
private const val MAX_CAST_MEMBERS = 16
private const val MAX_GUEST_CAST_MEMBERS = 8
/**
* Long enough that a run of presses coalesces into one seek, short enough that a
* single press feels like it did something. It is also why the OSD outlives it:
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF24292E" />
<stroke
android:width="1dp"
android:color="#24FFFFFF" />
<corners android:radius="10dp" />
</shape>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Drawn over the portrait, not behind it, so the accent ring reads on the artwork rather
than being covered by it. A remote has no hover: the ring plus the scale is the only
thing saying which face is selected. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<stroke
android:width="2dp"
android:color="#FF52B54B" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<stroke
android:width="1dp"
android:color="#1FFFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The cast panel has no card edge. It sits on somebody's film, so it fades into the
picture from the bottom rather than dropping a box over it: the scene stays visible
above the names, which is the whole reason the panel was opened. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:centerColor="#D40A0C0F"
android:centerY="0.35"
android:endColor="#00000000"
android:startColor="#FA07080A"
android:type="linear" />
</shape>
@@ -53,9 +53,6 @@
overlay deliberately stays in Memby's TV design language. -->
<include layout="@layout/player_subtitle_overlay" />
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
<include layout="@layout/player_cast_overlay" />
<!-- Service alerts, the same bar the launcher shows. Declared before the loading and
error overlays so those cover it: a notice about the library is not what someone
staring at a failed stream needs. Never focusable, so it cannot take the remote
@@ -93,6 +93,7 @@
android:visibility="gone" />
<LinearLayout
android:id="@+id/player_transport_panel"
android:layout_width="match_parent"
android:layout_height="210dp"
android:layout_gravity="bottom"
@@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The cast panel. It covers the bottom of somebody's film, so it is a fade rather than a
card: the scrim carries the names and the scene stays legible above them. The paddings
match the transport row's 48dp side inset, so opening this does not shift the column
the title and controls are read in. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_cast_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/player_cast_scrim"
android:clickable="true"
android:clipChildren="false"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingStart="48dp"
android:paddingTop="20dp"
android:paddingEnd="48dp"
android:paddingBottom="54dp">
<TextView
android:id="@+id/player_cast_eyebrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_cast_eyebrow"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<!-- The title, not the word "Cast". The eyebrow above already says what the panel
is, and a viewer who opened this mid-scene wants confirmation of what they are
watching more than a heading repeating the button they just pressed. -->
<TextView
android:id="@+id/player_cast_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFFFFFFF"
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_cast_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/player_cast_loading"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
<HorizontalScrollView
android:id="@+id/player_cast_scroller"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clipChildren="false"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_cast_people"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:gravity="top"
android:orientation="horizontal" />
</HorizontalScrollView>
<LinearLayout
android:id="@+id/player_cast_profile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="vertical"
android:visibility="gone" />
<TextView
android:id="@+id/player_cast_back_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:letterSpacing="0.08"
android:text="@string/player_back_to_close"
android:textColor="#6EFFFFFF"
android:textSize="11sp" />
</LinearLayout>
</FrameLayout>
@@ -4,5 +4,4 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:importantForAccessibility="no"
android:visibility="visible" />
+4 -10
View File
@@ -6,7 +6,7 @@
<string name="playback_retrying_now">Trying the stream again…</string>
<string name="playback_refreshing_stream">Requesting a fresh stream from Emby…</string>
<string name="playback_server_unreachable">Media server unavailable</string>
<string name="playback_server_unreachable_detail">Memby couldnt request a fresh stream. Check that the server is online, then try again.</string>
<string name="playback_server_unreachable_detail">Memby couldnt request a fresh stream. The server may be offline.</string>
<string name="playback_try_again">Try again</string>
<string name="playback_back_to_memby">Back to Memby</string>
<plurals name="playback_retrying_in">
@@ -23,17 +23,11 @@
<string name="player_live">Live</string>
<string name="player_back">Back to previous screen</string>
<string name="player_hide_controls">Hide controls</string>
<string name="player_cast">Cast</string>
<string name="player_cast_eyebrow">CAST</string>
<string name="player_cast_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string>
<string name="player_cast_profile_eyebrow">CAST PROFILE</string>
<string name="player_cast_profile_loading">Loading biography and filmography…</string>
<string name="player_guest_cast">GUEST CAST</string>
<string name="player_cast_profile_loading">Loading cast info</string>
<string name="player_cast_biography_empty">No biography is available.</string>
<string name="player_cast_filmography">FILMOGRAPHY</string>
<string name="player_cast_filmography_empty">No films or series are available.</string>
<string name="player_cast_back_to_cast">BACK · CAST</string>
<string name="player_cast_deceased">Deceased</string>
<string name="player_subtitle_track">SUBTITLES</string>
<string name="player_text_size">TEXT SIZE</string>
<string name="player_subtitle_download">GET SUBTITLES</string>
@@ -47,7 +41,7 @@
<!-- Shown in place of the track list on a backend that cannot fetch one, where there
is nothing to offer and the only honest thing to do is say so. -->
<string name="player_subtitle_none_unavailable">This title has no subtitles, and none can be downloaded.</string>
<string name="player_subtitle_searching">Looking for subtitles. This can take a moment.</string>
<string name="player_subtitle_searching">Searching for subtitles. Wait a few moments.</string>
<string name="player_subtitle_search_empty">No subtitles were found for this release.</string>
<string name="player_subtitle_downloading">Downloading %1$s subtitles…</string>
<string name="player_subtitle_download_failed">That subtitle could not be downloaded. Try another.</string>
@@ -25,27 +25,42 @@ class CastMetadataTest {
requireNotNull(items[id])
}
assertEquals(seriesCast, result)
assertEquals(ResolvedCast(members = seriesCast), result)
assertEquals(listOf("episode", "series"), requested)
}
@Test
fun `episode cast takes precedence over series cast`() = runTest {
val guestCast = listOf(actor("guest"))
fun `episode cast is separated from the regular series cast`() = runTest {
val regular = actor("regular")
val guest = actor("guest")
val requested = mutableListOf<String>()
val result = resolveCast("episode") { id ->
requested += id
BaseItem(
id = id,
type = "Episode",
seriesId = "series",
people = guestCast,
)
if (id == "episode") {
BaseItem(id = id, type = "Episode", seriesId = "series", people = listOf(regular, guest))
} else {
BaseItem(id = id, type = "Series", people = listOf(regular))
}
}
assertEquals(guestCast, result)
assertEquals(listOf("episode"), requested)
assertEquals(ResolvedCast(members = listOf(regular), guests = listOf(guest)), result)
assertEquals(listOf("episode", "series"), requested)
}
@Test
fun `episode cast becomes primary when the series has none`() = runTest {
val episodeCast = listOf(actor("guest"))
val result = resolveCast("episode") { id ->
if (id == "episode") {
BaseItem(id = id, type = "Episode", seriesId = "series", people = episodeCast)
} else {
BaseItem(id = id, type = "Series")
}
}
assertEquals(ResolvedCast(members = episodeCast), result)
}
@Test
@@ -57,7 +72,7 @@ class CastMetadataTest {
BaseItem(id = id, type = "Movie", seriesId = "series")
}
assertEquals(emptyList<EmbyPerson>(), result)
assertEquals(ResolvedCast(), result)
assertEquals(listOf("movie"), requested)
}
@@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayMagicPick
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewayPreferences
@@ -521,6 +522,22 @@ class GatewayPayloadTest {
assertEquals(false, playback.skipIntroAvailable)
}
@Test
fun `a Magic pick retains the selected movie artwork metadata`() {
val pick = json.decodeFromString<GatewayMagicPick>(
"""{"item":{"Id":"movie-9","Name":"Arrival","Type":"Movie","Overview":"A linguist meets visitors.","RunTimeTicks":69600000000,"ImageTags":{"Primary":"poster-tag"},"BackdropImageTags":["backdrop-tag"]},"reasons":["Because you watch science fiction"]}""",
)
val selected = requireNotNull(MagicPick.fromGateway(pick))
val item = selected.item
assertEquals("movie-9", item.id)
assertEquals("poster-tag", item.imageTags["Primary"])
assertEquals(listOf("backdrop-tag"), item.backdropImageTags)
assertEquals("A linguist meets visitors.", item.overview)
assertEquals(69_600_000_000L, item.runTimeTicks)
assertEquals(listOf("Because you watch science fiction"), selected.reasons)
}
@Test
fun `decodes an intro segment`() {
val intro = json.decodeFromString<GatewayIntro>(
@@ -135,6 +135,47 @@ class EpisodeDetailTest {
assertNull(episodeAfter(library, episode(9, 9)))
}
@Test
fun `a partially watched route gains its description without losing its resume position`() {
val route = episode(3, 2).copy(
overview = null,
userData = UserItemData(playbackPositionTicks = 420_000_000L),
)
val detailed = route.copy(
overview = "The team follows a signal into the hills.",
userData = UserItemData(playbackPositionTicks = 0L),
)
val resolved = episodeItemWithCatalogueOverview(route, listOf(detailed))
assertEquals("The team follows a signal into the hills.", resolved.overview)
assertEquals(420_000_000L, resolved.userData?.playbackPositionTicks)
}
@Test
fun `a next up route gains its description and remains unstarted`() {
val route = episode(4, 1).copy(overview = null, userData = UserItemData())
val detailed = route.copy(
overview = "A new arrival changes the investigation.",
userData = UserItemData(playbackPositionTicks = 180_000_000L),
)
val resolved = episodeItemWithCatalogueOverview(route, listOf(detailed))
assertEquals("A new arrival changes the investigation.", resolved.overview)
assertEquals(0L, resolved.userData?.playbackPositionTicks)
}
@Test
fun `an existing route description remains authoritative`() {
val route = episode(2, 1).copy(overview = "Fresh episode details.")
val cachedCatalogueEpisode = route.copy(overview = "Older catalogue details.")
val resolved = episodeItemWithCatalogueOverview(route, listOf(cachedCatalogueEpisode))
assertEquals("Fresh episode details.", resolved.overview)
}
private val library = listOf(
episode(1, 1, played = true),
episode(1, 2, played = true),
@@ -1,153 +1,109 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the cast panel to PNGs under `build/screenshots/cast-panel/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*CastPanelScreenshotTest"
* ```
*
* Captured over a playback still, because the panel has no card of its own — it is a fade
* into the picture, and the only way to judge whether the names are legible over a bright
* frame is to put them over one. Artwork cannot be fetched here, which is the point of the
* injectable loader: every capture shows the initials fallback, the state a real cast row
* is partly in anyway.
*/
/** Renders the Compose cast section as part of the paused hero. */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class CastPanelScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `a full cast with the first face focused`() {
capture(
name = "cast-panel-loaded",
state = CastPanelState(
title = "The Lives of Others",
fun `regular and episode guest cast share the paused hero`() {
render(
CastPanelState(
loaded = true,
members = listOf(
CastMember(
"Ulrich Mühe",
"Hauptmann Gerd Wiesler",
deathDate = "2007-07-22T00:00:00.0000000Z",
),
CastMember("Martina Gedeck", "Christa-Maria Sieland"),
CastMember("Sebastian Koch", "Georg Dreyman"),
CastMember("Ulrich Tukur", "Oberstleutnant Anton Grubitz"),
CastMember("Thomas Thieme", "Minister Bruno Hempf"),
CastMember("Hans-Uwe Bauer", "Paul Hauser"),
CastMember("Volkmar Kleinert", "Albert Jerska"),
CastMember("Ulrich Mühe", "Gerd Wiesler", id = "regular-1"),
CastMember("Martina Gedeck", "Christa-Maria Sieland", id = "regular-2"),
CastMember("Sebastian Koch", "Georg Dreyman", id = "regular-3"),
CastMember("Ulrich Tukur", "Anton Grubitz", id = "regular-4"),
CastMember("Thomas Thieme", "Bruno Hempf", id = "regular-5"),
),
guests = listOf(
CastMember("Marie Gruber", "Mrs Meineke", id = "guest-1"),
CastMember("Herbert Knaup", "Gregor Hessenstein", id = "guest-2"),
CastMember("Bastian Trost", "Häftling 227", id = "guest-3"),
),
),
focused = 0,
)
}
/** Focus part-way along, which is what the row looks like while somebody scrolls it. */
@Test
fun `focus part way along the row`() {
capture(
name = "cast-panel-focus-mid-row",
state = CastPanelState(
title = "Arrival",
loaded = true,
members = listOf(
CastMember("Amy Adams", "Louise Banks"),
CastMember("Jeremy Renner", "Ian Donnelly"),
CastMember("Forest Whitaker", "Colonel Weber"),
CastMember("Michael Stuhlbarg", "Agent Halpern"),
CastMember("Tzi Ma", "General Shang"),
),
),
focused = 2,
)
}
/** A name with no character recorded — the card must not leave a gap where the role was. */
@Test
fun `people with no role`() {
capture(
name = "cast-panel-no-roles",
state = CastPanelState(
title = "Koyaanisqatsi",
loaded = true,
members = listOf(
CastMember("Lou Dobbs"),
CastMember("Ted Koppel"),
CastMember("Philip Glass"),
),
),
focused = 0,
)
compose.onNodeWithTag("cast-member-Ulrich Mühe").assertIsFocused()
compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-with-guests.png")
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.onNodeWithTag("guest-cast-Marie Gruber").assertIsFocused()
}
@Test
fun `selected actor shows biography and filmography`() {
capture(
name = "cast-panel-person-profile",
state = CastPanelState(
title = "The Lives of Others",
fun `guest section is absent when Emby supplies none`() {
render(
CastPanelState(
loaded = true,
members = listOf(
CastMember("Amy Adams", "Louise Banks", id = "person-1"),
CastMember("Jeremy Renner", "Ian Donnelly", id = "person-2"),
CastMember("Forest Whitaker", "Colonel Weber", id = "person-3"),
),
),
)
compose.onNodeWithText("GUEST CAST").assertDoesNotExist()
compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-regular.png")
}
@Test
fun `selected actor profile remains inside the hero section`() {
render(
CastPanelState(
loaded = true,
members = listOf(CastMember("Ulrich Mühe", id = "person-1")),
selectedPerson = CastPersonPanel(
id = "person-1",
name = "Ulrich Mühe",
role = "Hauptmann Gerd Wiesler",
role = "Gerd Wiesler",
birthDate = "1953-06-20T00:00:00.0000000Z",
deathDate = "2007-07-22T00:00:00.0000000Z",
overview = "A celebrated German actor known for quiet, exacting performances on stage and screen. His portrayal of Gerd Wiesler earned international acclaim.",
overview = "A celebrated German actor known for quiet, exacting performances.",
filmography = listOf(
FilmographyCredit("The Lives of Others", 2006),
FilmographyCredit("Funny Games", 1997),
FilmographyCredit("The Castle", 1997),
FilmographyCredit("Benny's Video", 1992),
),
loaded = true,
),
),
)
}
/** Still fetching. The panel opens instantly and says so rather than showing nothing. */
@Test
fun `still loading`() {
capture(name = "cast-panel-loading", state = CastPanelState(title = "Arrival"))
}
/** A title Emby holds no cast for, which must read differently from "still loading". */
@Test
fun `no cast recorded`() {
capture(
name = "cast-panel-empty",
state = CastPanelState(title = "Home video, 1998", loaded = true),
)
compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-profile.png")
}
@Test
fun `initials cover a mononym, a middle name and a blank`() {
fun `initials and life dates retain their plain Kotlin contract`() {
assertEquals("AA", castInitials("Amy Adams"))
// First and surname, not the first two words: the surname is what identifies
// somebody, so a middle name is skipped rather than taking the second slot.
assertEquals("PH", castInitials("Philip Seymour Hoffman"))
assertEquals("C", castInitials("Cher"))
assertEquals("AA", castInitials(" amy adams "))
assertEquals("", castInitials(" "))
assertEquals(
"20 June 1953 22 July 2007",
@@ -155,43 +111,27 @@ class CastPanelScreenshotTest {
)
}
private fun capture(name: String, state: CastPanelState, focused: Int? = null) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
root.addView(
ImageView(activity).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageBitmap(previewArtwork())
},
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
),
)
val overlay = LayoutInflater.from(activity)
.inflate(R.layout.player_cast_overlay, root, false)
root.addView(overlay)
overlay.visibility = View.VISIBLE
bindCastPanel(overlay, state)
activity.setContentView(root)
if (focused != null) {
// Robolectric starts in touch mode, where a focusable card refuses focus and the
// capture would show every face unfocused. This leaves touch mode the way a
// D-pad press does.
val card = requireNotNull(
overlay.findViewById<LinearLayout>(R.id.player_cast_people).getChildAt(focused),
)
card.requestFocusFromTouch()
check(card.isFocused) { "the card to capture under focus never took it" }
private fun render(state: CastPanelState) {
val artwork = requireNotNull(javaClass.classLoader?.getResource("home_hero_preview_art.png"))
.toExternalForm()
compose.setContent {
MembyTheme {
PauseMediaHero(
metadata = PauseMediaHeroMetadata(
title = "The Lives of Others Episode Six",
seriesName = "The Lives of Others",
episodeCode = "S01E06",
overview = "A surveillance officer begins to question the world around him.",
backdropUrl = artwork,
primaryArtworkUrl = artwork,
),
visible = true,
castVisible = true,
castState = state,
onCastMemberClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
root.captureRoboImage("build/screenshots/cast-panel/$name.png")
}
private fun previewArtwork() =
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream)
}
@@ -47,12 +47,5 @@ class PrerollLayoutTest {
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_frame))
assertNotNull(preroll.findViewById<GridLayout>(R.id.player_preroll_calendar))
assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS)
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_cast_overlay,
FrameLayout(context),
false,
),
)
}
}