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