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