App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -121,7 +121,6 @@ import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.detail.ratingLabel
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
@@ -129,7 +128,6 @@ import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyScore
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -158,7 +156,8 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||
SETTINGS("Settings", Icons.Default.Settings),
|
||||
}
|
||||
|
||||
enum class MediaRowKind { CONTINUE, NEXT_UP, MOVIES, SHOWS, FAVORITES }
|
||||
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
||||
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
|
||||
|
||||
data class HomeBrowseRow(
|
||||
val id: String,
|
||||
@@ -179,8 +178,8 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
row.id == "continue" -> HomeRowVisual(
|
||||
Icons.Default.PlayCircleFilled,
|
||||
)
|
||||
row.id == "next-up" -> HomeRowVisual(
|
||||
Icons.Default.SkipNext,
|
||||
row.id == "continue-shows" -> HomeRowVisual(
|
||||
Icons.Default.PlayCircleFilled,
|
||||
)
|
||||
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
|
||||
Icons.Default.Favorite,
|
||||
@@ -1041,33 +1040,30 @@ private fun MetadataContent(
|
||||
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val facts = buildList {
|
||||
item.episodeCode?.let(::add)
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) }
|
||||
}
|
||||
// The score is its own run of text so it can carry the same gold as the detail
|
||||
// page. Joined into the grey fact line it was the one rating in the app that
|
||||
// changed colour depending on which screen you were looking at.
|
||||
val score = ratingLabel(item)
|
||||
if (facts.isNotEmpty() || score != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
score?.let {
|
||||
if (facts.isNotEmpty()) Text(FactSeparator, color = QuietText, fontSize = 13.sp)
|
||||
Text("★ $it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
// No score on this line. Emby's community rating stood beside these facts in gold
|
||||
// with nothing saying whose number it was; the strip below says that for every
|
||||
// score it shows, and two ratings in one panel only invited the comparison.
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ItemRatingsStrip(
|
||||
item = item,
|
||||
load = true,
|
||||
compact = compact,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val badges = buildList {
|
||||
airingBadgeLabel(item)?.let(::add)
|
||||
addAll(mediaBadges(item))
|
||||
@@ -1430,10 +1426,14 @@ internal fun MediaRow(
|
||||
onItemFocused(item)
|
||||
}
|
||||
when (cardFormat(row.kind, item, artworkStyle)) {
|
||||
// The resume bar belongs to the row, not to the card's shape: a
|
||||
// household that has set artwork to posters still needs to see
|
||||
// how far into something it is.
|
||||
MediaCardFormat.PORTRAIT -> PortraitMediaCard(
|
||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
||||
density, row.showWatchedEpisodeCount,
|
||||
showProgress = row.kind == MediaRowKind.CONTINUE,
|
||||
)
|
||||
MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
|
||||
ContinueWatchingCard(
|
||||
@@ -1708,7 +1708,7 @@ private fun cardFormat(
|
||||
): MediaCardFormat = when {
|
||||
artworkStyle == "poster" -> MediaCardFormat.PORTRAIT
|
||||
artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
kind == MediaRowKind.CONTINUE || kind == MediaRowKind.NEXT_UP -> MediaCardFormat.LANDSCAPE
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
}
|
||||
@@ -1724,6 +1724,7 @@ fun PortraitMediaCard(
|
||||
modifier: Modifier = Modifier,
|
||||
density: String = "standard",
|
||||
showWatchedEpisodeCount: Boolean = false,
|
||||
showProgress: Boolean = false,
|
||||
) {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 8
|
||||
@@ -1732,7 +1733,7 @@ fun PortraitMediaCard(
|
||||
}
|
||||
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
MediaCard(
|
||||
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
|
||||
item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress,
|
||||
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
|
||||
)
|
||||
}
|
||||
@@ -1941,10 +1942,23 @@ private fun MediaCard(
|
||||
}
|
||||
}
|
||||
if (item.isSchedule) {
|
||||
ScheduleStatusBadge(
|
||||
status = item.membyAvailability.orEmpty(),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
)
|
||||
scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let {
|
||||
ScheduleStatusBadge(
|
||||
status = item.membyAvailability.orEmpty(),
|
||||
label = it,
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
)
|
||||
}
|
||||
// Bottom-left, because the two top corners already carry the air day and
|
||||
// this episode's availability, and the progress bar owns the bottom edge
|
||||
// only on cards that can be resumed — which a schedule card cannot.
|
||||
lifecycleBadgeLabel(item)?.let {
|
||||
LifecycleBadge(
|
||||
status = item.membyLifecycle.orEmpty(),
|
||||
label = it,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
airingBadgeLabel(item)?.let { label ->
|
||||
MediaBadge(
|
||||
@@ -1975,13 +1989,18 @@ private fun MediaCard(
|
||||
} else {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
}
|
||||
ItemRatingsStrip(
|
||||
item = item,
|
||||
load = focused,
|
||||
compact = true,
|
||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
|
||||
val label = scheduleStatusBadgeLabel(status)
|
||||
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
val color = when (status) {
|
||||
"available" -> EmbyGreen
|
||||
"downloading" -> Color(0xFF5DA9FF)
|
||||
@@ -2002,12 +2021,53 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
internal fun scheduleStatusBadgeLabel(status: String): String = when (status) {
|
||||
internal fun scheduleStatusBadgeLabel(status: String): String? = when (status) {
|
||||
"available" -> "ADDED"
|
||||
"downloading" -> "DOWNLOADING"
|
||||
"awaiting" -> "AWAITING"
|
||||
"unmonitored" -> "UNMONITORED"
|
||||
else -> "UPCOMING"
|
||||
// The day badge and air-time subtitle already say when a future episode airs.
|
||||
// Repeating "upcoming" adds no state and can collide with the more useful day.
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The show's or film's lifecycle, as Sonarr and Radarr word it — CONTINUING, ENDED, IN
|
||||
* CINEMAS. It answers a different question from the availability badge above it: that one
|
||||
* is about the household's copy of this episode, this one is about whether there will be
|
||||
* any more of them.
|
||||
*
|
||||
* The wording is the gateway's, taken verbatim off the card, so a status a build predates
|
||||
* still reads correctly rather than falling back to a slug. A card carrying no lifecycle —
|
||||
* an older gateway, a cached row, a show *arr has no status for — simply wears no tag.
|
||||
*/
|
||||
internal fun lifecycleBadgeLabel(item: BaseItem): String? =
|
||||
item.membyLifecycleText?.trim()?.takeIf(String::isNotEmpty)?.uppercase()
|
||||
|
||||
@Composable
|
||||
internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
// One colour per status, matching what the same word is coloured in *arr: still being
|
||||
// made is green, over is red, not out yet is blue, in cinemas is amber.
|
||||
val (background, foreground) = when (status) {
|
||||
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
|
||||
"upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D)
|
||||
"incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D)
|
||||
"ended" -> Color(0xFFE04747) to Color.White
|
||||
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = foreground,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(background)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -2199,6 +2259,7 @@ private fun cardDescription(
|
||||
if (item.isSchedule) {
|
||||
item.membyAirLabel?.let { append(", ").append(it) }
|
||||
item.membyAvailabilityText?.let { append(", ").append(it) }
|
||||
item.membyLifecycleText?.let { append(", ").append(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user