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: