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:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
@@ -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) }
}
}