From 63c3df11b2de49c0d7bbb3d8ad4c045508e68ddb Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 23 Aug 2026 14:16:03 +1200 Subject: [PATCH] 0.3.08 --- CLAUDE.md | 33 +- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/data/CastMetadata.kt | 32 +- .../memby/data/EmbyRepository.kt | 39 +- .../memby/ui/EpisodeDetailsOverlay.kt | 25 +- .../com/ponzischeme89/memby/ui/HomeScreen.kt | 34 +- .../memby/ui/player/CastPanel.kt | 660 ++++++++++-------- .../memby/ui/player/PauseMediaHero.kt | 39 +- .../memby/ui/player/PlayerActivity.kt | 193 +++-- .../player_cast_portrait_background.xml | 9 - .../drawable/player_cast_portrait_frame.xml | 24 - .../main/res/drawable/player_cast_scrim.xml | 14 - app/src/main/res/layout/activity_player.xml | 3 - .../main/res/layout/memby_player_controls.xml | 1 + .../main/res/layout/player_cast_overlay.xml | 100 --- .../main/res/layout/player_pause_overlay.xml | 1 - app/src/main/res/values/strings.xml | 14 +- .../memby/data/CastMetadataTest.kt | 39 +- .../memby/data/GatewayPayloadTest.kt | 17 + .../memby/ui/EpisodeDetailTest.kt | 41 ++ .../ui/player/CastPanelScreenshotTest.kt | 218 +++--- .../memby/ui/player/PrerollLayoutTest.kt | 7 - server/internal/buildinfo/VERSION | 2 +- 23 files changed, 789 insertions(+), 758 deletions(-) delete mode 100644 app/src/main/res/drawable/player_cast_portrait_background.xml delete mode 100644 app/src/main/res/drawable/player_cast_portrait_frame.xml delete mode 100644 app/src/main/res/drawable/player_cast_scrim.xml delete mode 100644 app/src/main/res/layout/player_cast_overlay.xml diff --git a/CLAUDE.md b/CLAUDE.md index 1ce4cf1..ae6c9d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 - edge so the scene stays legible above the names — which is the reason somebody opened it. - The 48dp side inset matches the transport row, so opening it does not shift the column the - title and controls are read in. -- **The heading is the title, not the word "Cast".** The accent eyebrow above already says - what the panel is; repeating the button just pressed costs the line that could confirm - what is being watched. +- **Cast is the paused hero's lower section, not a second full-screen overlay.** Opening it + pauses playback, leaves the hero's logo/title anchor fixed, hides the transport and + crossfades only the poster/synopsis region into `CastPanel`. The panel therefore inherits + the hero's backdrop, scrims, 48dp safe inset, palette, spacing and panel radius rather than + carrying a parallel XML design. Back reverses that hand-off and returns focus to the Cast + transport button. +- **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 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 has to decide in advance whether artwork will arrive. -- **The focus ring is the `foreground`**, drawn over the artwork, and the portrait takes - `duplicateParentState` because the *card* is what is focusable. A remote has no hover: the - ring and the scale are the only thing saying which face is selected. -- `bindCastPanel` in `ui/player/CastPanel.kt` takes a `CastPanelState` and an injected image - loader, so `CastPanelScreenshotTest` renders the real cards with no player, server or - network → `build/screenshots/cast-panel/`. `loaded` is separate from an empty list because - "still fetching" and "no cast recorded" are different things to be told. +- **Focus enters after composition and loading.** `CastPanel` requests the first regular + face, the first guest when it is the only row, or the first filmography card in a profile. + Guest faces use the same portrait component and horizontal D-pad model as regular cast. +- `CastPanel` takes plain `CastPanelState`, so `CastPanelScreenshotTest` renders it inside the + real paused hero with no player or server → `build/screenshots/cast-panel/`. `loaded` is + separate from an empty list because "still fetching" and "no cast recorded" are different + things to be told. **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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bbc9a90..40ac69d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt b/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt index 9da7a09..2e2dd7b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/CastMetadata.kt @@ -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 = emptyList(), + val guests: List = 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 { +): 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()}" diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 9d509da..e14860f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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 = 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? { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index 475a2f9..bcc6955 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -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 { + 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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 3be9d2b..32eb52e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -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, ), ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt index 8d74bff..1995280 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt @@ -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 = emptyList(), + val guests: List = 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(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(R.id.player_cast_title).apply { - text = selected?.name ?: state.title - isVisible = !text.isNullOrBlank() - } - overlay.findViewById(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(R.id.player_cast_people) - people.removeAllViews() - state.members.forEach { member -> - people.addView(castCard(context, member, loadImage, onMemberClick)) - } - overlay.findViewById(R.id.player_cast_scroller).isVisible = - selected == null && state.members.isNotEmpty() - overlay.findViewById(R.id.player_cast_profile).apply { - isVisible = selected?.loaded == true - removeAllViews() - selected?.takeIf { it.loaded }?.let { bindPersonProfile(this, it, loadImage) } - } - overlay.findViewById(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, + 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PauseMediaHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PauseMediaHero.kt index a021e3f..2ff550c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PauseMediaHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PauseMediaHero.kt @@ -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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 37a9adf..e7585e5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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 = emptyList() + private var castGuests: List = emptyList() private var castLoaded = false private var castProfiles: Map = 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(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(R.id.player_cast)?.post { + playerView?.findViewById(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(R.id.player_cast_people) - .getChildAt(0) - ?.requestFocus() - ?: overlay.requestFocus() - } else { - overlay.findViewById(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: diff --git a/app/src/main/res/drawable/player_cast_portrait_background.xml b/app/src/main/res/drawable/player_cast_portrait_background.xml deleted file mode 100644 index a5677e8..0000000 --- a/app/src/main/res/drawable/player_cast_portrait_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/player_cast_portrait_frame.xml b/app/src/main/res/drawable/player_cast_portrait_frame.xml deleted file mode 100644 index ceefac5..0000000 --- a/app/src/main/res/drawable/player_cast_portrait_frame.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/player_cast_scrim.xml b/app/src/main/res/drawable/player_cast_scrim.xml deleted file mode 100644 index 912640f..0000000 --- a/app/src/main/res/drawable/player_cast_scrim.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 8ea5b6e..f8609ac 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -53,9 +53,6 @@ overlay deliberately stays in Memby's TV design language. --> - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/player_pause_overlay.xml b/app/src/main/res/layout/player_pause_overlay.xml index 3b50686..4bc02f8 100644 --- a/app/src/main/res/layout/player_pause_overlay.xml +++ b/app/src/main/res/layout/player_pause_overlay.xml @@ -4,5 +4,4 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:focusable="false" - android:importantForAccessibility="no" android:visibility="visible" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f007f3..bbf5a01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,7 +6,7 @@ Trying the stream again… Requesting a fresh stream from Emby… Media server unavailable - Memby couldn’t request a fresh stream. Check that the server is online, then try again. + Memby couldn’t request a fresh stream. The server may be offline. Try again Back to Memby @@ -23,17 +23,11 @@ Live Back to previous screen Hide controls - Cast - CAST Loading cast… No cast information is available. - CAST PROFILE - Loading biography and filmography… + GUEST CAST + Loading cast info No biography is available. - FILMOGRAPHY - No films or series are available. - BACK · CAST - Deceased SUBTITLES TEXT SIZE GET SUBTITLES @@ -47,7 +41,7 @@ This title has no subtitles, and none can be downloaded. - Looking for subtitles. This can take a moment. + Searching for subtitles. Wait a few moments. No subtitles were found for this release. Downloading %1$s subtitles… That subtitle could not be downloaded. Try another. diff --git a/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt index 63478ee..2b3252d 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/CastMetadataTest.kt @@ -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() 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(), result) + assertEquals(ResolvedCast(), result) assertEquals(listOf("movie"), requested) } diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index 2f101f6..11d9c84 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -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( + """{"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( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailTest.kt index b97c127..e335f90 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailTest.kt @@ -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), diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt index 117ddf8..778dc35 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/CastPanelScreenshotTest.kt @@ -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(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) } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt index b4f0753..46697c5 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PrerollLayoutTest.kt @@ -47,12 +47,5 @@ class PrerollLayoutTest { assertNotNull(context.getDrawable(R.drawable.player_preroll_video_frame)) assertNotNull(preroll.findViewById(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, - ), - ) } } diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 9810a3b..c24b7ee 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.64 +0.1.66