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
+19 -14
View File
@@ -1821,24 +1821,29 @@ asked mid-scene and one that has to survive a dialog and four menu rows is one n
twice. It is deliberately *not* also in `showTrackMenu`'s list: one thing reachable two ways twice. It is deliberately *not* also in `showTrackMenu`'s list: one thing reachable two ways
is one thing whose two entry points drift apart. Things to preserve: is one thing whose two entry points drift apart. Things to preserve:
- **The panel is a fade, not a card.** `player_cast_scrim` carries it up from the bottom - **Cast is the paused hero's lower section, not a second full-screen overlay.** Opening it
edge so the scene stays legible above the names — which is the reason somebody opened it. pauses playback, leaves the hero's logo/title anchor fixed, hides the transport and
The 48dp side inset matches the transport row, so opening it does not shift the column the crossfades only the poster/synopsis region into `CastPanel`. The panel therefore inherits
title and controls are read in. the hero's backdrop, scrims, 48dp safe inset, palette, spacing and panel radius rather than
- **The heading is the title, not the word "Cast".** The accent eyebrow above already says carrying a parallel XML design. Back reverses that hand-off and returns focus to the Cast
what the panel is; repeating the button just pressed costs the line that could confirm transport button.
what is being watched. - **There is no redundant Cast heading.** The button and the faces already establish the
context. `GUEST CAST` is the only section label, and appears only for episode-only people.
- **Episodes read both levels of Emby's credits.** `resolveCast` uses the series' `People`
for the regular row and removes those identities from the episode's `People` to obtain the
guest row. A library with no series cast falls back to the episode row as its primary cast,
so adding guest separation never turns useful metadata into an empty panel.
- **Initials sit behind every portrait** (`castInitials`, pure and tested). Emby has no - **Initials sit behind every portrait** (`castInitials`, pure and tested). Emby has no
photo for a good part of a typical cast, and a row of identical grey rectangles says photo for a good part of a typical cast, and a row of identical grey rectangles says
nothing about which name is which. They are behind rather than instead of the image, so nothing about which name is which. They are behind rather than instead of the image, so
nothing has to decide in advance whether artwork will arrive. nothing has to decide in advance whether artwork will arrive.
- **The focus ring is the `foreground`**, drawn over the artwork, and the portrait takes - **Focus enters after composition and loading.** `CastPanel` requests the first regular
`duplicateParentState` because the *card* is what is focusable. A remote has no hover: the face, the first guest when it is the only row, or the first filmography card in a profile.
ring and the scale are the only thing saying which face is selected. Guest faces use the same portrait component and horizontal D-pad model as regular cast.
- `bindCastPanel` in `ui/player/CastPanel.kt` takes a `CastPanelState` and an injected image - `CastPanel` takes plain `CastPanelState`, so `CastPanelScreenshotTest` renders it inside the
loader, so `CastPanelScreenshotTest` renders the real cards with no player, server or real paused hero with no player or server `build/screenshots/cast-panel/`. `loaded` is
network → `build/screenshots/cast-panel/`. `loaded` is separate from an empty list because separate from an empty list because "still fetching" and "no cast recorded" are different
"still fetching" and "no cast recorded" are different things to be told. things to be told.
**Time to first frame** is the number playback is judged by, and a *resume* is the worst **Time to first frame** is the number playback is judged by, and a *resume* is the worst
case: it is a seek, and a seek over HTTP is several more requests before a single frame is case: it is a seek, and a seek over HTTP is several more requests before a single frame is
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.07" val defaultVersionName = "0.3.08"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -3,22 +3,40 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson 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. * An episode's `People` describes that episode, while its parent series carries the stable
* Keep episode-specific credits when Emby supplies them, and only pay for the series * cast. Read both for episodes so the player can keep the familiar cast row and place only
* request when the episode has no cast of its own. * 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( internal suspend fun resolveCast(
itemId: String, itemId: String,
loadItem: suspend (String) -> BaseItem, loadItem: suspend (String) -> BaseItem,
): List<EmbyPerson> { ): ResolvedCast {
val item = loadItem(itemId) val item = loadItem(itemId)
if (item.cast.isNotEmpty()) return item.cast if (!item.isEpisode) return ResolvedCast(members = item.cast)
val seriesId = item.seriesId val seriesId = item.seriesId
?.takeIf(String::isNotBlank) ?.takeIf(String::isNotBlank)
?.takeUnless { it == item.id } ?.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.MembyViewer
import com.ponzischeme89.memby.data.model.MembyViewerRequest import com.ponzischeme89.memby.data.model.MembyViewerRequest
import com.ponzischeme89.memby.data.model.GatewayLoginRequest 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.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
import com.ponzischeme89.memby.data.model.GatewayRowEvent 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 * One film Magic drew. [item] remains Emby's complete item model so selecting it can enter
* [Playable]: nothing has been negotiated yet, and resolving a stream for a title the viewer * the same playback-request and artwork pipeline as a film selected from a shelf.
* has not yet been shown would create a playback session for something nobody watched. *
* 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( data class MagicPick(
val itemId: String, val item: BaseItem,
val title: String,
val itemType: String = "",
val overview: String? = null,
val runtimeMs: Long = 0L,
val logoUrl: String? = null,
val backdropUrl: String? = null,
val reasons: List<String> = emptyList(), 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. */ /** A resolved, directly playable stream. */
data class Playable( data class Playable(
@@ -2709,17 +2714,7 @@ class EmbyRepository internal constructor(
com.ponzischeme89.memby.data.model.GatewayMagicRequest(excludeIds = excludeIds), com.ponzischeme89.memby.data.model.GatewayMagicRequest(excludeIds = excludeIds),
) )
}.getOrNull() ?: return null }.getOrNull() ?: return null
val item = response.item?.takeIf { it.id.isNotBlank() } ?: return null return MagicPick.fromGateway(response)
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,
)
} }
suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? { suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? {
@@ -126,8 +126,17 @@ fun EpisodeDetailsOverlay(
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList() 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( EpisodeDetailContent(
item = item, item = detailedItem,
episodes = episodes, episodes = episodes,
loadFailed = loadFailed, loadFailed = loadFailed,
onPlay = onPlay, 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. */ /** [episodes] is null while the series' episode list is still coming. */
@Composable @Composable
internal fun EpisodeDetailContent( internal fun EpisodeDetailContent(
@@ -698,30 +698,10 @@ internal fun HomeScreen(
playbackLauncher.launch( playbackLauncher.launch(
PlayerActivity.intent( PlayerActivity.intent(
context = context, context = context,
itemId = playable.itemId, playable = playable,
url = playable.url,
title = playable.title,
resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920) backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(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), 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, requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id, journeySource = entryPoint.id,
), ),
@@ -1,22 +1,63 @@
package com.ponzischeme89.memby.ui.player package com.ponzischeme89.memby.ui.player
import android.content.Context import androidx.compose.animation.Crossfade
import android.graphics.Color import androidx.compose.animation.core.tween
import android.graphics.Typeface import androidx.compose.foundation.background
import android.text.TextUtils import androidx.compose.foundation.border
import android.view.Gravity import androidx.compose.foundation.clickable
import android.view.View import androidx.compose.foundation.focusGroup
import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement
import android.widget.FrameLayout import androidx.compose.foundation.layout.Box
import android.widget.HorizontalScrollView import androidx.compose.foundation.layout.Column
import android.widget.ImageView import androidx.compose.foundation.layout.PaddingValues
import android.widget.LinearLayout import androidx.compose.foundation.layout.Row
import android.widget.TextView import androidx.compose.foundation.layout.Spacer
import androidx.core.view.isVisible 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.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( data class CastMember(
val name: String, val name: String,
val role: String? = null, val role: String? = null,
@@ -42,256 +83,350 @@ data class CastPersonPanel(
val loaded: Boolean = false, val loaded: Boolean = false,
) )
/** /** Plain state keeps the player as owner and makes this surface previewable and testable. */
* 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.
*/
data class CastPanelState( data class CastPanelState(
val title: String = "",
val members: List<CastMember> = emptyList(), val members: List<CastMember> = emptyList(),
val guests: List<CastMember> = emptyList(),
val loaded: Boolean = false, val loaded: Boolean = false,
val selectedPerson: CastPersonPanel? = null, 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` * It deliberately owns no full-screen scrim or programme heading: the paused hero already
* can render the real panel — these cards, these colours, this layout — with no player, no * supplies both. This is one quiet, rounded section in the hero's reading-safe area, and the
* Emby server and no network. [loadImage] is injected for the same reason: artwork is the * only secondary heading is Guest Cast when there are episode-only appearances to name.
* 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.
*/ */
fun bindCastPanel( @Composable
overlay: View, internal fun CastPanel(
state: CastPanelState, state: CastPanelState,
loadImage: (ImageView, String) -> Unit = { _, _ -> }, visible: Boolean,
onMemberClick: (CastMember) -> Unit = {}, 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 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( LaunchedEffect(
context: Context, visible,
member: CastMember, state.loaded,
loadImage: (ImageView, String) -> Unit, state.members.firstOrNull()?.id,
onClick: (CastMember) -> Unit, state.guests.firstOrNull()?.id,
): View { selected?.id,
val density = context.resources.displayMetrics.density selected?.loaded,
fun dp(value: Int) = (value * density).toInt() ) {
if (!visible) return@LaunchedEffect
return LinearLayout(context).apply { when {
orientation = LinearLayout.VERTICAL selected?.loaded == true && selected.filmography.isNotEmpty() ->
isFocusable = true firstFilmography.requestFocus()
isClickable = true selected == null && state.members.isNotEmpty() -> firstMember.requestFocus()
setOnClickListener { onClick(member) } selected == null && state.guests.isNotEmpty() -> firstGuest.requestFocus()
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()
} }
addView(castPortrait(context, member, loadImage, ::dp)) val shape = RoundedCornerShape(MembyPanelCorner)
addView(LinearLayout(context).apply { Box(
orientation = LinearLayout.HORIZONTAL modifier = modifier
gravity = Gravity.CENTER_VERTICAL .clip(shape)
layoutParams = LinearLayout.LayoutParams( .background(MembySurfaceRaised.copy(alpha = 0.94f))
ViewGroup.LayoutParams.MATCH_PARENT, .border(1.dp, MembyHairline, shape)
ViewGroup.LayoutParams.WRAP_CONTENT, .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,
) )
setPadding(0, dp(9), 0, 0) } else {
addView(TextView(context).apply { CastProfile(person = person, firstFilmography = firstFilmography)
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)))
} }
})
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)
},
)
} }
} }
} }
private fun bindPersonProfile( @Composable
container: LinearLayout, private fun CastRows(
person: CastPersonPanel, state: CastPanelState,
loadImage: (ImageView, String) -> Unit, firstMember: FocusRequester,
firstGuest: FocusRequester,
onMemberClick: (CastMember) -> Unit,
) { ) {
val context = container.context when {
val density = context.resources.displayMetrics.density !state.loaded -> CastStatus(stringResource(R.string.player_cast_loading))
fun dp(value: Int) = (value * density).toInt() state.members.isEmpty() && state.guests.isEmpty() ->
val life = personLifeDates(person.birthDate, person.deathDate) CastStatus(stringResource(R.string.player_cast_empty))
if (life.isNotBlank() || !person.role.isNullOrBlank()) { else -> Column(Modifier.fillMaxSize()) {
container.addView(TextView(context).apply { if (state.members.isNotEmpty()) {
typeface = context.membyTypeface() CastRow(
text = listOfNotNull( members = state.members,
person.role?.takeIf(String::isNotBlank), firstFocus = firstMember,
life.takeIf(String::isNotBlank), onMemberClick = onMemberClick,
).joinToString(" · ") cardWidth = 104.dp,
setTextColor(Color.rgb(185, 193, 200)) portraitHeight = 112.dp,
textSize = 13f modifier = Modifier.weight(1f),
maxLines = 1 )
ellipsize = TextUtils.TruncateAt.END
})
} }
container.addView(TextView(context).apply { if (state.guests.isNotEmpty()) {
typeface = context.membyTypeface() if (state.members.isNotEmpty()) Spacer(Modifier.height(8.dp))
text = person.overview?.takeIf(String::isNotBlank) Text(
?: context.getString(R.string.player_cast_biography_empty) text = stringResource(R.string.player_guest_cast),
setTextColor(Color.WHITE) color = MembyAccentBright,
textSize = 14f fontSize = 11.sp,
maxLines = 3 lineHeight = 14.sp,
ellipsize = TextUtils.TruncateAt.END fontWeight = FontWeight.Bold,
setLineSpacing(0f, 1.08f) letterSpacing = 1.2.sp,
setPadding(0, dp(7), 0, 0) )
}) Spacer(Modifier.height(5.dp))
container.addView(TextView(context).apply { CastRow(
typeface = context.membyTypeface(Typeface.BOLD) members = state.guests,
text = context.getString(R.string.player_cast_filmography) firstFocus = firstGuest,
setTextColor(Color.rgb(82, 181, 75)) onMemberClick = onMemberClick,
textSize = 11f cardWidth = 176.dp,
letterSpacing = 0.12f portraitHeight = 58.dp,
setPadding(0, dp(13), 0, dp(5)) compact = true,
}) modifier = Modifier.height(64.dp),
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
}) @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,
)
}
}
}
@Composable
private fun CastMemberCard(
member: CastMember,
onClick: () -> Unit,
cardWidth: Dp,
portraitHeight: Dp,
compact: Boolean,
modifier: Modifier = Modifier,
) {
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,
)
}
}
}
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 return
} }
container.addView(HorizontalScrollView(context).apply { Column(Modifier.fillMaxSize()) {
isHorizontalScrollBarEnabled = false Text(
overScrollMode = View.OVER_SCROLL_NEVER text = person.name,
clipChildren = false color = MembyOnSurface,
addView(LinearLayout(context).apply { fontSize = 20.sp,
orientation = LinearLayout.HORIZONTAL lineHeight = 24.sp,
clipChildren = false fontWeight = FontWeight.SemiBold,
person.filmography.forEach { credit -> )
addView(filmographyCard(context, credit, loadImage, ::dp)) 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( @Composable
context: Context, private fun FilmographyCard(credit: FilmographyCredit, modifier: Modifier = Modifier) {
credit: FilmographyCredit, var focused by remember { mutableStateOf(false) }
loadImage: (ImageView, String) -> Unit, val shape = RoundedCornerShape(MembyCardCorner)
dp: (Int) -> Int, Row(
): View = LinearLayout(context).apply { modifier = modifier
orientation = LinearLayout.HORIZONTAL .width(180.dp)
gravity = Gravity.CENTER_VERTICAL .height(62.dp)
isFocusable = true .detailCardFocus(focused)
isClickable = true .clip(shape)
setOnClickListener { } .background(MembyControlSurface)
background = context.getDrawable(R.drawable.player_cast_portrait_background) .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyHairline, shape)
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame) .onFocusChanged { focused = it.isFocused }
setOnFocusChangeListener { view, focused -> .clickable(onClick = {}),
view.animate() verticalAlignment = Alignment.CenterVertically,
.scaleX(if (focused) 1.03f else 1f) ) {
.scaleY(if (focused) 1.03f else 1f) if (credit.imageUrl != null) {
.setDuration(120L) AsyncImage(
.start() model = credit.imageUrl,
} contentDescription = credit.title,
setPadding(dp(5), dp(5), dp(9), dp(5)) contentScale = ContentScale.Crop,
layoutParams = LinearLayout.LayoutParams(dp(194), dp(68)).apply { marginEnd = dp(10) } modifier = Modifier.width(44.dp).fillMaxHeight(),
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,
) )
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 { internal fun personLifeDates(birthDate: String?, deathDate: String?): String {
@@ -315,59 +450,6 @@ private fun formatPersonDate(value: String?): String {
return "${day.toIntOrNull() ?: day} $monthName $year" 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 { internal fun castInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter(String::isNotBlank) val parts = name.trim().split(Regex("\\s+")).filter(String::isNotBlank)
return when (parts.size) { return when (parts.size) {
@@ -376,3 +458,5 @@ internal fun castInitials(name: String): String {
else -> (parts.first().take(1) + parts.last().take(1)).uppercase() 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 package com.ponzischeme89.memby.ui.player
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -75,10 +76,13 @@ internal data class PauseMediaHeroMetadata(
internal fun PauseMediaHero( internal fun PauseMediaHero(
metadata: PauseMediaHeroMetadata, metadata: PauseMediaHeroMetadata,
visible: Boolean, visible: Boolean,
castVisible: Boolean = false,
castState: CastPanelState = CastPanelState(),
onCastMemberClick: (CastMember) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible || castVisible,
modifier = modifier, modifier = modifier,
enter = fadeIn(tween(PauseHeroEnterMs)) + enter = fadeIn(tween(PauseHeroEnterMs)) +
slideInVertically(tween(PauseHeroEnterMs)) { height -> height / 40 }, slideInVertically(tween(PauseHeroEnterMs)) { height -> height / 40 },
@@ -89,9 +93,17 @@ internal fun PauseMediaHero(
PauseHeroBackdrop(metadata.backdropUrl) PauseHeroBackdrop(metadata.backdropUrl)
PauseHeroContent( PauseHeroContent(
metadata = metadata, metadata = metadata,
castVisible = castVisible,
castState = castState,
onCastMemberClick = onCastMemberClick,
modifier = Modifier modifier = Modifier
.fillMaxSize() .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 @Composable
private fun PauseHeroContent( private fun PauseHeroContent(
metadata: PauseMediaHeroMetadata, metadata: PauseMediaHeroMetadata,
castVisible: Boolean,
castState: CastPanelState,
onCastMemberClick: (CastMember) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier, verticalArrangement = Arrangement.Top) { Column(modifier, verticalArrangement = Arrangement.Top) {
@@ -181,9 +196,24 @@ private fun PauseHeroContent(
) )
} }
} }
Spacer(Modifier.height(18.dp)) 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) PauseHeroSummary(metadata)
} }
}
}
} }
@Composable @Composable
@@ -290,3 +320,4 @@ private fun pauseHeroEpisodeTitle(title: String, seriesName: String?): String? {
private const val PauseHeroEnterMs = 180 private const val PauseHeroEnterMs = 180
private const val PauseHeroExitMs = 120 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. */ /** Shown once a manually selected subtitle survives a media-item reload. */
private var pendingSubtitleConfirmation: String? = null private var pendingSubtitleConfirmation: String? = null
private var subtitleOverlay: View? = null private var subtitleOverlay: View? = null
private var castOverlay: View? = null
private var castJob: Job? = null private var castJob: Job? = null
private var castPeople: List<EmbyPerson> = emptyList() private var castPeople: List<EmbyPerson> = emptyList()
private var castGuests: List<EmbyPerson> = emptyList()
private var castLoaded = false private var castLoaded = false
private var castProfiles: Map<String, BaseItem> = emptyMap() private var castProfiles: Map<String, BaseItem> = emptyMap()
private var selectedCastPerson: CastPersonPanel? = null private var selectedCastPerson: CastPersonPanel? = null
private var castPersonJob: Job? = 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 prerollView: View? = null
private var localPrerollPlayer: ExoPlayer? = null private var localPrerollPlayer: ExoPlayer? = null
private var localPrerollView: PlayerView? = null private var localPrerollView: PlayerView? = null
@@ -976,7 +979,6 @@ class PlayerActivity : ComponentActivity() {
logoUrl = logoUrl, logoUrl = logoUrl,
) )
setUpSubtitleOverlay() setUpSubtitleOverlay()
setUpCastOverlay()
setUpNextUpBanner() setUpNextUpBanner()
setUpSkipIntro() setUpSkipIntro()
setUpEndCredits() setUpEndCredits()
@@ -2706,7 +2708,7 @@ class PlayerActivity : ComponentActivity() {
playback.duration > 0L && playback.duration > 0L &&
playback.duration != C.TIME_UNSET && playback.duration != C.TIME_UNSET &&
playerView?.isControllerFullyVisible != true && playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true && !castPanelVisible.value &&
subtitleOverlay?.isVisible != true && subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true && nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true && creditsView?.isVisible != true &&
@@ -3042,7 +3044,7 @@ class PlayerActivity : ComponentActivity() {
playbackStarted && playbackStarted &&
!prerollActive && !prerollActive &&
playerView?.isControllerFullyVisible != true && playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true && !castPanelVisible.value &&
subtitleOverlay?.isVisible != true && subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true && nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true && creditsView?.isVisible != true &&
@@ -3415,8 +3417,21 @@ class PlayerActivity : ComponentActivity() {
.show() .show()
return@launch return@launch
} }
magicOffered += pick.itemId val selectedItem = pick.item
magicOffered += selectedItem.id
if (magicOffered.size > MAGIC_MEMORY) magicOffered.removeAt(0) 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 // 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 // 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 // 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) recordPlaybackFinishedJourney(completed = false)
PlaybackJourney.magicSelected( PlaybackJourney.magicSelected(
sink = JourneyTracker, sink = JourneyTracker,
itemId = pick.itemId, itemId = selectedItem.id,
itemName = pick.title, itemName = selectedItem.name,
itemType = pick.itemType, itemType = selectedItem.type,
) )
PlaybackJourney.requested( PlaybackJourney.requested(
sink = JourneyTracker, sink = JourneyTracker,
entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE,
screen = "player", screen = "player",
itemId = pick.itemId, itemId = selectedItem.id,
itemName = pick.title, itemName = selectedItem.name,
itemType = pick.itemType, itemType = selectedItem.type,
) )
Toast.makeText( Toast.makeText(
this@PlayerActivity, this@PlayerActivity,
getString(R.string.player_magic_selected, pick.title), getString(R.string.player_magic_selected, selectedItem.name),
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
// A film is a new subject, not the next step of this one, so it goes through the // 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. // nothing playing.
startActivity( startActivity(
intent( intent(
this@PlayerActivity, context = this@PlayerActivity,
PlaybackRequest( playable = playable,
itemId = pick.itemId, posterUrl = repository.primaryUrl(selectedItem, maxWidth = 500),
itemType = pick.itemType, backdropUrl = repository.backdropUrl(selectedItem, maxWidth = 1920)
title = pick.title, ?: repository.primaryUrl(selectedItem, maxWidth = 1920),
overview = pick.overview,
runtimeMs = pick.runtimeMs,
logoUrl = pick.logoUrl,
),
backdropUrl = pick.backdropUrl,
journeySource = PlaybackEntryPoint.MAGIC_MOVIE.id, journeySource = PlaybackEntryPoint.MAGIC_MOVIE.id,
), ),
) )
@@ -4375,15 +4385,21 @@ class PlayerActivity : ComponentActivity() {
setContent { setContent {
val visible by pauseHeroVisible val visible by pauseHeroVisible
val metadata by pauseHeroMetadata val metadata by pauseHeroMetadata
val castVisible by castPanelVisible
val castState by castPanelState
MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) { MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
PauseMediaHero( PauseMediaHero(
metadata = metadata, metadata = metadata,
visible = visible, visible = visible,
castVisible = castVisible,
castState = castState,
onCastMemberClick = ::showCastPerson,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
} }
} }
transportPanel = view.findViewById(R.id.player_transport_panel)
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group) 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 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. // 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. // activity before our UP handler gets a chance to hide the active surface.
if (event.action == KeyEvent.ACTION_UP) { if (event.action == KeyEvent.ACTION_UP) {
when { when {
castOverlay?.isVisible == true && selectedCastPerson != null -> castPanelVisible.value && selectedCastPerson != null ->
showCastList() showCastList()
castOverlay?.isVisible == true -> hideCastOverlay() castPanelVisible.value -> hideCastOverlay()
// The drop-up's download half is a level of its own, so Back leaves it // 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. // before it leaves the menu — one press per level, never two at once.
subtitleOverlay?.isVisible == true && subtitleDownloadExpanded -> subtitleOverlay?.isVisible == true && subtitleDownloadExpanded ->
@@ -4494,7 +4510,7 @@ class PlayerActivity : ComponentActivity() {
!prerollActive && !prerollActive &&
player != null && player != null &&
playerView?.isControllerFullyVisible != true && playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true && !castPanelVisible.value &&
subtitleOverlay?.isVisible != true && subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true && nextUpBanner?.isVisible != true &&
// The pane's Play button holds focus while it is up, and the centre key is how a // 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() { private fun loadCast() {
castJob?.cancel() castJob?.cancel()
castPersonJob?.cancel() castPersonJob?.cancel()
castLoaded = false castLoaded = false
castPeople = emptyList() castPeople = emptyList()
castGuests = emptyList()
castProfiles = emptyMap() castProfiles = emptyMap()
selectedCastPerson = null selectedCastPerson = null
val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run { val requestedItemId = itemId?.takeIf(String::isNotBlank) ?: run {
@@ -4763,15 +4774,17 @@ class PlayerActivity : ComponentActivity() {
castJob = lifecycleScope.launch { castJob = lifecycleScope.launch {
val loaded = runCatching { val loaded = runCatching {
resolveCast(requestedItemId, ServiceLocator.repository::getItemDetails) resolveCast(requestedItemId, ServiceLocator.repository::getItemDetails)
.take(MAX_CAST_MEMBERS) }.getOrDefault(com.ponzischeme89.memby.data.ResolvedCast())
}.getOrDefault(emptyList()) val members = loaded.members.take(MAX_CAST_MEMBERS)
val guests = loaded.guests.take(MAX_GUEST_CAST_MEMBERS)
if (itemId == requestedItemId) { if (itemId == requestedItemId) {
castPeople = loaded castPeople = members
castGuests = guests
castLoaded = true castLoaded = true
bindCastOverlay() bindCastOverlay()
} }
val profiles = coroutineScope { val profiles = coroutineScope {
loaded.filter { it.id.isNotBlank() }.map { person -> (members + guests).distinctBy(EmbyPerson::id).filter { it.id.isNotBlank() }.map { person ->
async { async {
runCatching { ServiceLocator.repository.getPersonDetails(person.id) } runCatching { ServiceLocator.repository.getPersonDetails(person.id) }
.getOrNull() .getOrNull()
@@ -4787,8 +4800,13 @@ class PlayerActivity : ComponentActivity() {
private fun showCastOverlay() { private fun showCastOverlay() {
if (playingTrailer) return if (playingTrailer) return
playerView?.hideController() player?.pause()
castOverlay?.visibility = View.VISIBLE updatePauseHeroMetadata()
playerView?.showController()
transportPanel?.visibility = View.GONE
pauseOverlay?.isFocusable = true
castPanelVisible.value = true
pauseOverlay?.requestFocus()
bindCastOverlay() bindCastOverlay()
if (!castLoaded && castJob?.isActive != true) loadCast() if (!castLoaded && castJob?.isActive != true) loadCast()
} }
@@ -4796,8 +4814,14 @@ class PlayerActivity : ComponentActivity() {
private fun hideCastOverlay() { private fun hideCastOverlay() {
castPersonJob?.cancel() castPersonJob?.cancel()
selectedCastPerson = null selectedCastPerson = null
castOverlay?.visibility = View.GONE castPanelVisible.value = false
pauseOverlay?.isFocusable = false
bindCastOverlay()
transportPanel?.visibility = View.VISIBLE
playerView?.showController() playerView?.showController()
playerView?.findViewById<View>(R.id.player_cast)?.post {
playerView?.findViewById<View>(R.id.player_cast)?.requestFocus()
}
} }
private fun showCastList() { private fun showCastList() {
@@ -4852,43 +4876,22 @@ class PlayerActivity : ComponentActivity() {
} }
private fun bindCastOverlay() { private fun bindCastOverlay() {
val overlay = castOverlay ?: return fun EmbyPerson.toCastMember(): CastMember {
bindCastPanel( val profile = castProfiles[id]
overlay = overlay, return CastMember(
state = CastPanelState( name = name,
title = playbackTitle, role = role,
members = castPeople.map { person -> imageUrl = ServiceLocator.repository.personImageUrl(this, 320),
val profile = castProfiles[person.id] id = id,
CastMember(
name = person.name,
role = person.role,
imageUrl = ServiceLocator.repository.personImageUrl(person, 320),
id = person.id,
deathDate = profile?.endDate, deathDate = profile?.endDate,
) )
}, }
castPanelState.value = CastPanelState(
members = castPeople.map { it.toCastMember() },
guests = castGuests.map { it.toCastMember() },
loaded = castLoaded, loaded = castLoaded,
selectedPerson = selectedCastPerson, 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()
}
}
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@@ -5928,6 +5931,47 @@ class PlayerActivity : ComponentActivity() {
resumePositionMs: Long = 0L, resumePositionMs: Long = 0L,
): Intent = intent(context, itemId = null, url = url, title = title, resumePositionMs = resumePositionMs) ): 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( fun intent(
context: Context, context: Context,
itemId: String?, itemId: String?,
@@ -6039,6 +6083,7 @@ class PlayerActivity : ComponentActivity() {
private const val PREROLL_FADE_MS = 180L private const val PREROLL_FADE_MS = 180L
private const val PREROLL_TRANSITION_MS = 480L private const val PREROLL_TRANSITION_MS = 480L
private const val MAX_CAST_MEMBERS = 16 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 * 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: * 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. --> overlay deliberately stays in Memby's TV design language. -->
<include layout="@layout/player_subtitle_overlay" /> <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 <!-- 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 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 staring at a failed stream needs. Never focusable, so it cannot take the remote
@@ -93,6 +93,7 @@
android:visibility="gone" /> android:visibility="gone" />
<LinearLayout <LinearLayout
android:id="@+id/player_transport_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="210dp" android:layout_height="210dp"
android:layout_gravity="bottom" 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_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:focusable="false" android:focusable="false"
android:importantForAccessibility="no"
android:visibility="visible" /> android:visibility="visible" />
+4 -10
View File
@@ -6,7 +6,7 @@
<string name="playback_retrying_now">Trying the stream again…</string> <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_refreshing_stream">Requesting a fresh stream from Emby…</string>
<string name="playback_server_unreachable">Media server unavailable</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_try_again">Try again</string>
<string name="playback_back_to_memby">Back to Memby</string> <string name="playback_back_to_memby">Back to Memby</string>
<plurals name="playback_retrying_in"> <plurals name="playback_retrying_in">
@@ -23,17 +23,11 @@
<string name="player_live">Live</string> <string name="player_live">Live</string>
<string name="player_back">Back to previous screen</string> <string name="player_back">Back to previous screen</string>
<string name="player_hide_controls">Hide controls</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_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string> <string name="player_cast_empty">No cast information is available.</string>
<string name="player_cast_profile_eyebrow">CAST PROFILE</string> <string name="player_guest_cast">GUEST CAST</string>
<string name="player_cast_profile_loading">Loading biography and filmography…</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_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_subtitle_track">SUBTITLES</string>
<string name="player_text_size">TEXT SIZE</string> <string name="player_text_size">TEXT SIZE</string>
<string name="player_subtitle_download">GET SUBTITLES</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 <!-- 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. --> 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_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_search_empty">No subtitles were found for this release.</string>
<string name="player_subtitle_downloading">Downloading %1$s subtitles…</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> <string name="player_subtitle_download_failed">That subtitle could not be downloaded. Try another.</string>
@@ -25,27 +25,42 @@ class CastMetadataTest {
requireNotNull(items[id]) requireNotNull(items[id])
} }
assertEquals(seriesCast, result) assertEquals(ResolvedCast(members = seriesCast), result)
assertEquals(listOf("episode", "series"), requested) assertEquals(listOf("episode", "series"), requested)
} }
@Test @Test
fun `episode cast takes precedence over series cast`() = runTest { fun `episode cast is separated from the regular series cast`() = runTest {
val guestCast = listOf(actor("guest")) val regular = actor("regular")
val guest = actor("guest")
val requested = mutableListOf<String>() val requested = mutableListOf<String>()
val result = resolveCast("episode") { id -> val result = resolveCast("episode") { id ->
requested += id requested += id
BaseItem( if (id == "episode") {
id = id, BaseItem(id = id, type = "Episode", seriesId = "series", people = listOf(regular, guest))
type = "Episode", } else {
seriesId = "series", BaseItem(id = id, type = "Series", people = listOf(regular))
people = guestCast, }
)
} }
assertEquals(guestCast, result) assertEquals(ResolvedCast(members = listOf(regular), guests = listOf(guest)), result)
assertEquals(listOf("episode"), requested) 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 @Test
@@ -57,7 +72,7 @@ class CastMetadataTest {
BaseItem(id = id, type = "Movie", seriesId = "series") BaseItem(id = id, type = "Movie", seriesId = "series")
} }
assertEquals(emptyList<EmbyPerson>(), result) assertEquals(ResolvedCast(), result)
assertEquals(listOf("movie"), requested) 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.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayMovieRatings 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.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewayPreferences import com.ponzischeme89.memby.data.model.GatewayPreferences
@@ -521,6 +522,22 @@ class GatewayPayloadTest {
assertEquals(false, playback.skipIntroAvailable) 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 @Test
fun `decodes an intro segment`() { fun `decodes an intro segment`() {
val intro = json.decodeFromString<GatewayIntro>( val intro = json.decodeFromString<GatewayIntro>(
@@ -135,6 +135,47 @@ class EpisodeDetailTest {
assertNull(episodeAfter(library, episode(9, 9))) 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( private val library = listOf(
episode(1, 1, played = true), episode(1, 1, played = true),
episode(1, 2, played = true), episode(1, 2, played = true),
@@ -1,153 +1,109 @@
package com.ponzischeme89.memby.ui.player package com.ponzischeme89.memby.ui.player
import android.app.Activity import androidx.compose.foundation.layout.fillMaxSize
import android.graphics.BitmapFactory import androidx.compose.ui.Modifier
import android.view.LayoutInflater import androidx.compose.ui.input.key.Key
import android.view.View import androidx.compose.ui.test.assertDoesNotExist
import android.widget.FrameLayout import androidx.compose.ui.test.assertIsFocused
import android.widget.ImageView import androidx.compose.ui.test.junit4.v2.createComposeRule
import android.widget.LinearLayout 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.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
/** /** Renders the Compose cast section as part of the paused hero. */
* 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.
*/
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") @Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class CastPanelScreenshotTest { class CastPanelScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test @Test
fun `a full cast with the first face focused`() { fun `regular and episode guest cast share the paused hero`() {
capture( render(
name = "cast-panel-loaded", CastPanelState(
state = CastPanelState(
title = "The Lives of Others",
loaded = true, loaded = true,
members = listOf( members = listOf(
CastMember( CastMember("Ulrich Mühe", "Gerd Wiesler", id = "regular-1"),
"Ulrich Mühe", CastMember("Martina Gedeck", "Christa-Maria Sieland", id = "regular-2"),
"Hauptmann Gerd Wiesler", CastMember("Sebastian Koch", "Georg Dreyman", id = "regular-3"),
deathDate = "2007-07-22T00:00:00.0000000Z", CastMember("Ulrich Tukur", "Anton Grubitz", id = "regular-4"),
CastMember("Thomas Thieme", "Bruno Hempf", id = "regular-5"),
), ),
CastMember("Martina Gedeck", "Christa-Maria Sieland"), guests = listOf(
CastMember("Sebastian Koch", "Georg Dreyman"), CastMember("Marie Gruber", "Mrs Meineke", id = "guest-1"),
CastMember("Ulrich Tukur", "Oberstleutnant Anton Grubitz"), CastMember("Herbert Knaup", "Gregor Hessenstein", id = "guest-2"),
CastMember("Thomas Thieme", "Minister Bruno Hempf"), CastMember("Bastian Trost", "Häftling 227", id = "guest-3"),
CastMember("Hans-Uwe Bauer", "Paul Hauser"),
CastMember("Volkmar Kleinert", "Albert Jerska"),
), ),
), ),
focused = 0,
) )
}
/** Focus part-way along, which is what the row looks like while somebody scrolls it. */ compose.onNodeWithTag("cast-member-Ulrich Mühe").assertIsFocused()
@Test compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-with-guests.png")
fun `focus part way along the row`() { compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
capture( compose.onNodeWithTag("guest-cast-Marie Gruber").assertIsFocused()
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,
)
} }
@Test @Test
fun `selected actor shows biography and filmography`() { fun `guest section is absent when Emby supplies none`() {
capture( render(
name = "cast-panel-person-profile", CastPanelState(
state = CastPanelState( loaded = true,
title = "The Lives of Others", 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, loaded = true,
members = listOf(CastMember("Ulrich Mühe", id = "person-1")), members = listOf(CastMember("Ulrich Mühe", id = "person-1")),
selectedPerson = CastPersonPanel( selectedPerson = CastPersonPanel(
id = "person-1", id = "person-1",
name = "Ulrich Mühe", name = "Ulrich Mühe",
role = "Hauptmann Gerd Wiesler", role = "Gerd Wiesler",
birthDate = "1953-06-20T00:00:00.0000000Z", birthDate = "1953-06-20T00:00:00.0000000Z",
deathDate = "2007-07-22T00: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( filmography = listOf(
FilmographyCredit("The Lives of Others", 2006), FilmographyCredit("The Lives of Others", 2006),
FilmographyCredit("Funny Games", 1997), FilmographyCredit("Funny Games", 1997),
FilmographyCredit("The Castle", 1997), FilmographyCredit("The Castle", 1997),
FilmographyCredit("Benny's Video", 1992),
), ),
loaded = true, loaded = true,
), ),
), ),
) )
}
/** Still fetching. The panel opens instantly and says so rather than showing nothing. */ compose.onRoot().captureRoboImage("build/screenshots/cast-panel/cast-panel-profile.png")
@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),
)
} }
@Test @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")) 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("PH", castInitials("Philip Seymour Hoffman"))
assertEquals("C", castInitials("Cher")) assertEquals("C", castInitials("Cher"))
assertEquals("AA", castInitials(" amy adams "))
assertEquals("", castInitials(" ")) assertEquals("", castInitials(" "))
assertEquals( assertEquals(
"20 June 1953 22 July 2007", "20 June 1953 22 July 2007",
@@ -155,43 +111,27 @@ class CastPanelScreenshotTest {
) )
} }
private fun capture(name: String, state: CastPanelState, focused: Int? = null) { private fun render(state: CastPanelState) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val artwork = requireNotNull(javaClass.classLoader?.getResource("home_hero_preview_art.png"))
val root = FrameLayout(activity) .toExternalForm()
root.addView( compose.setContent {
ImageView(activity).apply { MembyTheme {
scaleType = ImageView.ScaleType.CENTER_CROP PauseMediaHero(
setImageBitmap(previewArtwork()) metadata = PauseMediaHeroMetadata(
}, title = "The Lives of Others Episode Six",
FrameLayout.LayoutParams( seriesName = "The Lives of Others",
FrameLayout.LayoutParams.MATCH_PARENT, episodeCode = "S01E06",
FrameLayout.LayoutParams.MATCH_PARENT, 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(),
) )
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" }
} }
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(context.getDrawable(R.drawable.player_preroll_video_frame))
assertNotNull(preroll.findViewById<GridLayout>(R.id.player_preroll_calendar)) assertNotNull(preroll.findViewById<GridLayout>(R.id.player_preroll_calendar))
assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS) assertEquals(6_500L, PlayerActivity.DEFAULT_PREROLL_DURATION_MS)
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_cast_overlay,
FrameLayout(context),
false,
),
)
} }
} }
+1 -1
View File
@@ -1 +1 @@
0.1.64 0.1.66