This commit is contained in:
ponzischeme89
2026-08-22 20:01:49 +12:00
parent 7d77700c7b
commit d2f7f84cbf
24 changed files with 395 additions and 117 deletions
+1 -1
View File
@@ -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.03" val defaultVersionName = "0.3.04"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -34,6 +34,10 @@ object ServiceLocator {
lateinit var remoteConfig: RemoteConfigManager lateinit var remoteConfig: RemoteConfigManager
private set private set
/** Safe for previews and view-only screenshot fixtures that do not initialise the app. */
internal fun activeFontFamilyName(): String =
if (::remoteConfig.isInitialized) remoteConfig.active.presentation.fontFamily else "system"
/** /**
* Held rather than discarded because it is a long-lived collector, not a service * Held rather than discarded because it is a long-lived collector, not a service
* anything calls: it starts working when it is constructed and must not be collected * anything calls: it starts working when it is constructed and must not be collected
@@ -1208,8 +1208,8 @@ internal fun DetailFactRow(
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp) if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium) Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
} }
// Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped // Four, not three: picture, audio and airing metadata can all share this line, and
// the airing badge appended after them, which is the one that is news. // the airing badge appended after them is the one that is news.
badges.take(4).forEach { badge -> badges.take(4).forEach { badge ->
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
MediaBadge(badge) MediaBadge(badge)
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -386,7 +387,16 @@ private fun FeaturedMovieCard(
contentDescription = "Featured, ${pick.label}, ${item.name}", contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused -> ) { focused ->
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) { Box(
Modifier
.fillMaxSize()
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyPanelCorner),
),
) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
@@ -539,7 +549,16 @@ private fun MiniMovieCard(
contentDescription = "${pick.label} movie, ${item.name}", contentDescription = "${pick.label} movie, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused -> ) { focused ->
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) { Box(
Modifier
.fillMaxSize()
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.Transparent,
RoundedCornerShape(MembyPanelCorner),
),
) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(labelTint(pick.label))) Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
Box( Box(
@@ -25,6 +25,7 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.membyTypeface
/** /**
* One answer in a television settings panel. The panel owns its invariant chrome while * One answer in a television settings panel. The panel owns its invariant chrome while
@@ -170,7 +171,7 @@ fun tvSettingsOptionView(
val title = TextView(context).apply { val title = TextView(context).apply {
text = label text = label
textSize = if (compact) 14f else 16f textSize = if (compact) 14f else 16f
typeface = Typeface.create("sans-serif", Typeface.BOLD) typeface = context.membyTypeface(Typeface.BOLD)
maxLines = 1 maxLines = 1
} }
copy.addView(title) copy.addView(title)
@@ -178,7 +179,7 @@ fun tvSettingsOptionView(
TextView(context).apply { TextView(context).apply {
this.text = text this.text = text
textSize = 12f textSize = 12f
typeface = Typeface.create("sans-serif", Typeface.NORMAL) typeface = context.membyTypeface()
maxLines = 2 maxLines = 2
copy.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { copy.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(2) topMargin = context.dp(2)
@@ -190,7 +191,7 @@ fun tvSettingsOptionView(
val trailing = TextView(context).apply { val trailing = TextView(context).apply {
text = if (selected) "CURRENT" else value text = if (selected) "CURRENT" else value
textSize = if (selected) 10f else 12f textSize = if (selected) 10f else 12f
typeface = Typeface.create("sans-serif", Typeface.BOLD) typeface = context.membyTypeface(Typeface.BOLD)
gravity = Gravity.CENTER gravity = Gravity.CENTER
letterSpacing = if (selected) 0.08f else 0f letterSpacing = if (selected) 0.08f else 0f
visibility = if (text.isBlank()) View.GONE else View.VISIBLE visibility = if (text.isBlank()) View.GONE else View.VISIBLE
@@ -271,14 +272,14 @@ private fun tvSettingsPanel(context: Context, title: String, description: String
text = title text = title
setTextColor(MembyOnSurface.toArgb()) setTextColor(MembyOnSurface.toArgb())
textSize = 22f textSize = 22f
typeface = Typeface.create("sans-serif", Typeface.BOLD) typeface = context.membyTypeface(Typeface.BOLD)
}) })
if (description.isNotBlank()) { if (description.isNotBlank()) {
root.addView(TextView(context).apply { root.addView(TextView(context).apply {
text = description text = description
setTextColor(MembyMutedText.toArgb()) setTextColor(MembyMutedText.toArgb())
textSize = 14f textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.NORMAL) typeface = context.membyTypeface()
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(5) topMargin = context.dp(5)
}) })
@@ -334,7 +335,7 @@ private fun tvSettingsAction(context: Context, label: String, primary: Boolean,
val button = TextView(context).apply { val button = TextView(context).apply {
text = label text = label
textSize = 14f textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD) typeface = context.membyTypeface(Typeface.BOLD)
gravity = Gravity.CENTER gravity = Gravity.CENTER
isFocusable = true isFocusable = true
isClickable = true isClickable = true
@@ -96,7 +96,6 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.channelLabel
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
@@ -143,6 +142,23 @@ fun MediaBadge(
fontSize: TextUnit = 11.sp, fontSize: TextUnit = 11.sp,
lineHeight: TextUnit = TextUnit.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified,
) { ) {
val surroundSoundDescription = when (label) {
"5.1" -> "5.1 surround sound"
"7.1" -> "7.1 surround sound"
"DOLBY ATMOS" -> "Dolby Atmos surround sound"
else -> null
}
if (surroundSoundDescription != null) {
Image(
painter = painterResource(R.drawable.icon_surround_sound),
contentDescription = surroundSoundDescription,
modifier = modifier
.height(18.dp)
.aspectRatio(1.5f),
)
return
}
Text( Text(
label, label,
color = MembyOnSurface, color = MembyOnSurface,
@@ -95,7 +95,6 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.channelLabel
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
@@ -202,9 +201,11 @@ internal fun mediaBadges(item: BaseItem): List<String> {
// HDR10+ in one place and plain HDR in the other. // HDR10+ in one place and plain HDR in the other.
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title) dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
?.let { add(it.uppercase(Locale.US)) } ?.let { add(it.uppercase(Locale.US)) }
if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC") when {
audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) } audio?.title?.contains("atmos", true) == true -> add("DOLBY ATMOS")
if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS") audio?.channels == 8 -> add("7.1")
audio?.channels == 6 -> add("5.1")
}
}.distinct() }.distinct()
} }
@@ -31,8 +31,11 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -237,13 +240,19 @@ private fun MetadataContent(
) { ) {
metadataHeroContentOrder(contentOrder).forEach { section -> metadataHeroContentOrder(contentOrder).forEach { section ->
when (section) { when (section) {
MetadataHeroSection.Title -> MetadataHeroTitle(item, logoUrl, logo, compact) MetadataHeroSection.Title -> MetadataHeroTitle(
item = item,
logoUrl = logoUrl,
logo = logo,
compact = compact,
showEpisodeLabel = !isContinueWatchingItem,
)
MetadataHeroSection.Ratings -> ItemRatingsStrip( MetadataHeroSection.Ratings -> ItemRatingsStrip(
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(), item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
) )
MetadataHeroSection.Facts -> { MetadataHeroSection.Facts -> {
// Catalogue facts and the outlined file badges are one metadata line. // Catalogue facts and the outlined file badges are one metadata line.
// Badges are measured first so Stereo/5.1 and picture formats remain // Badges are measured first so the surround marker and picture formats remain
// visible; a long genre list gives way through ellipsis before the // visible; a long genre list gives way through ellipsis before the
// file information wraps or escapes the hero. // file information wraps or escapes the hero.
if (facts.isNotEmpty() || badges.isNotEmpty()) { if (facts.isNotEmpty() || badges.isNotEmpty()) {
@@ -285,12 +294,48 @@ private fun MetadataContent(
colour = timeRemainingColour, colour = timeRemainingColour,
isContinueWatchingItem = isContinueWatchingItem, isContinueWatchingItem = isContinueWatchingItem,
) )
MetadataHeroSection.Summary -> Text( MetadataHeroSection.Summary -> Column(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.", verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) ) {
item.taglines
.firstOrNull { item.isMovie && it.isNotBlank() }
?.let { tagline ->
Text(
tagline,
color = EmbyGreen,
fontSize = 14.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val overview = item.overview
?.takeIf(String::isNotBlank)
?: "No description available."
val episodePrefix = if (isContinueWatchingItem && item.isEpisode) {
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)
} else {
null
}
Text(
buildAnnotatedString {
episodePrefix?.let { prefix ->
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(prefix)
}
append("")
}
append(overview)
},
color = MembyMutedText,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = if (compact) 2 else 3,
overflow = TextOverflow.Ellipsis,
)
}
} }
} }
MetadataStatus(item) MetadataStatus(item)
@@ -328,7 +373,13 @@ internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
} }
@Composable @Composable
private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) { private fun MetadataHeroTitle(
item: BaseItem,
logoUrl: String?,
logo: String?,
compact: Boolean,
showEpisodeLabel: Boolean,
) {
Box( Box(
modifier = Modifier.fillMaxWidth().then( modifier = Modifier.fillMaxWidth().then(
if (logoUrl != null) Modifier.height(MetadataHeroLogoMaxHeight) if (logoUrl != null) Modifier.height(MetadataHeroLogoMaxHeight)
@@ -349,7 +400,7 @@ private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, c
fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
} }
} }
if (item.isEpisode) { if (item.isEpisode && showEpisodeLabel) {
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label -> episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
Text( Text(
label, label,
@@ -125,8 +125,6 @@ enum class BrowseDestination(val label: String, val icon: MembyIcon) {
MOVIES("Movies", MembyIcon.Movie), MOVIES("Movies", MembyIcon.Movie),
SHOWS("TV Shows", MembyIcon.Tv), SHOWS("TV Shows", MembyIcon.Tv),
FOR_YOU("For You", MembyIcon.Sparkle), FOR_YOU("For You", MembyIcon.Sparkle),
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
// has one — see [TvNavigationRail]'s calendarEnabled.
CALENDAR("TV Calendar", MembyIcon.Calendar), CALENDAR("TV Calendar", MembyIcon.Calendar),
FAVORITES("Favourites", MembyIcon.Favourite), FAVORITES("Favourites", MembyIcon.Favourite),
PROFILES("User", MembyIcon.Person), PROFILES("User", MembyIcon.Person),
@@ -251,7 +249,7 @@ fun TvNavigationRail(
model = markPath, model = markPath,
contentDescription = "Memby", contentDescription = "Memby",
modifier = Modifier modifier = Modifier
.size(30.dp) .size(34.dp)
.graphicsLayer { .graphicsLayer {
scaleX = logoScale.value scaleX = logoScale.value
scaleY = logoScale.value scaleY = logoScale.value
@@ -261,7 +259,7 @@ fun TvNavigationRail(
) else Image( ) else Image(
painter = painterResource(R.drawable.memby_mark), painter = painterResource(R.drawable.memby_mark),
contentDescription = "Memby", contentDescription = "Memby",
modifier = Modifier.size(30.dp).graphicsLayer { modifier = Modifier.size(34.dp).graphicsLayer {
scaleX = logoScale.value; scaleY = logoScale.value; alpha = logoAlpha.value; rotationZ = logoRotation.value scaleX = logoScale.value; scaleY = logoScale.value; alpha = logoAlpha.value; rotationZ = logoRotation.value
}, },
) )
@@ -14,6 +14,7 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.membyTypeface
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */ /** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
data class CastMember( data class CastMember(
@@ -141,10 +142,10 @@ private fun castCard(
) )
setPadding(0, dp(9), 0, 0) setPadding(0, dp(9), 0, 0)
addView(TextView(context).apply { addView(TextView(context).apply {
typeface = context.membyTypeface(Typeface.BOLD)
text = member.name text = member.name
setTextColor(Color.WHITE) setTextColor(Color.WHITE)
textSize = 14f textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 1 maxLines = 1
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
@@ -159,6 +160,7 @@ private fun castCard(
member.role?.takeIf(String::isNotBlank)?.let { role -> member.role?.takeIf(String::isNotBlank)?.let { role ->
addView( addView(
TextView(context).apply { TextView(context).apply {
typeface = context.membyTypeface()
text = role text = role
setTextColor(Color.rgb(158, 168, 178)) setTextColor(Color.rgb(158, 168, 178))
textSize = 11f textSize = 11f
@@ -182,6 +184,7 @@ private fun bindPersonProfile(
val life = personLifeDates(person.birthDate, person.deathDate) val life = personLifeDates(person.birthDate, person.deathDate)
if (life.isNotBlank() || !person.role.isNullOrBlank()) { if (life.isNotBlank() || !person.role.isNullOrBlank()) {
container.addView(TextView(context).apply { container.addView(TextView(context).apply {
typeface = context.membyTypeface()
text = listOfNotNull( text = listOfNotNull(
person.role?.takeIf(String::isNotBlank), person.role?.takeIf(String::isNotBlank),
life.takeIf(String::isNotBlank), life.takeIf(String::isNotBlank),
@@ -193,6 +196,7 @@ private fun bindPersonProfile(
}) })
} }
container.addView(TextView(context).apply { container.addView(TextView(context).apply {
typeface = context.membyTypeface()
text = person.overview?.takeIf(String::isNotBlank) text = person.overview?.takeIf(String::isNotBlank)
?: context.getString(R.string.player_cast_biography_empty) ?: context.getString(R.string.player_cast_biography_empty)
setTextColor(Color.WHITE) setTextColor(Color.WHITE)
@@ -203,15 +207,16 @@ private fun bindPersonProfile(
setPadding(0, dp(7), 0, 0) setPadding(0, dp(7), 0, 0)
}) })
container.addView(TextView(context).apply { container.addView(TextView(context).apply {
typeface = context.membyTypeface(Typeface.BOLD)
text = context.getString(R.string.player_cast_filmography) text = context.getString(R.string.player_cast_filmography)
setTextColor(Color.rgb(82, 181, 75)) setTextColor(Color.rgb(82, 181, 75))
textSize = 11f textSize = 11f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
letterSpacing = 0.12f letterSpacing = 0.12f
setPadding(0, dp(13), 0, dp(5)) setPadding(0, dp(13), 0, dp(5))
}) })
if (person.filmography.isEmpty()) { if (person.filmography.isEmpty()) {
container.addView(TextView(context).apply { container.addView(TextView(context).apply {
typeface = context.membyTypeface()
text = context.getString(R.string.player_cast_filmography_empty) text = context.getString(R.string.player_cast_filmography_empty)
setTextColor(Color.rgb(185, 193, 200)) setTextColor(Color.rgb(185, 193, 200))
textSize = 13f textSize = 13f
@@ -271,15 +276,16 @@ private fun filmographyCard(
addView(LinearLayout(context).apply { addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(TextView(context).apply { addView(TextView(context).apply {
typeface = context.membyTypeface(Typeface.BOLD)
text = credit.title text = credit.title
setTextColor(Color.WHITE) setTextColor(Color.WHITE)
textSize = 12f textSize = 12f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 2 maxLines = 2
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
}) })
credit.year?.let { year -> credit.year?.let { year ->
addView(TextView(context).apply { addView(TextView(context).apply {
typeface = context.membyTypeface()
text = year.toString() text = year.toString()
setTextColor(Color.rgb(158, 168, 178)) setTextColor(Color.rgb(158, 168, 178))
textSize = 11f textSize = 11f
@@ -333,6 +339,7 @@ private fun castPortrait(
addView( addView(
TextView(context).apply { TextView(context).apply {
typeface = context.membyTypeface()
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
@@ -340,7 +347,6 @@ private fun castPortrait(
text = castInitials(member.name) text = castInitials(member.name)
setTextColor(Color.rgb(126, 138, 148)) setTextColor(Color.rgb(126, 138, 148))
textSize = 34f textSize = 34f
typeface = Typeface.create("sans-serif-light", Typeface.NORMAL)
gravity = Gravity.CENTER gravity = Gravity.CENTER
}, },
) )
@@ -104,7 +104,11 @@ import com.ponzischeme89.memby.ui.components.TvSettingsMenuOption
import com.ponzischeme89.memby.ui.components.showTvSettingsMenu import com.ponzischeme89.memby.ui.components.showTvSettingsMenu
import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu
import com.ponzischeme89.memby.ui.randomWelcomeQuote import com.ponzischeme89.memby.ui.randomWelcomeQuote
import com.ponzischeme89.memby.ui.theme.AppFontFamily
import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.theme.MembyTheme
import com.ponzischeme89.memby.ui.theme.applyMembyTypeface
import com.ponzischeme89.memby.ui.theme.appFontFamily
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -539,6 +543,9 @@ class PlayerActivity : ComponentActivity() {
@UnstableApi @UnstableApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (appFontFamily(ServiceLocator.remoteConfig.active.presentation.fontFamily) == AppFontFamily.INTER) {
setTheme(R.style.Theme_Memby_Inter_Fullscreen)
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -637,6 +644,7 @@ class PlayerActivity : ComponentActivity() {
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}") Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
setContentView(R.layout.activity_player) setContentView(R.layout.activity_player)
findViewById<View>(android.R.id.content).applyMembyTypeface()
val enhancementsAvailable = membyPlayerEnhancementsAvailable( val enhancementsAvailable = membyPlayerEnhancementsAvailable(
playingTrailer = playingTrailer, playingTrailer = playingTrailer,
) )
@@ -1764,6 +1772,7 @@ class PlayerActivity : ComponentActivity() {
private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View = private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply { LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
applyMembyTypeface()
findViewById<TextView>(R.id.player_preroll_card_label).text = label findViewById<TextView>(R.id.player_preroll_card_label).text = label
findViewById<TextView>(R.id.player_preroll_card_title).text = entry.series findViewById<TextView>(R.id.player_preroll_card_title).text = entry.series
findViewById<TextView>(R.id.player_preroll_card_detail).text = listOfNotNull( findViewById<TextView>(R.id.player_preroll_card_detail).text = listOfNotNull(
@@ -1784,6 +1793,7 @@ class PlayerActivity : ComponentActivity() {
private fun prerollMessage(message: String): TextView = private fun prerollMessage(message: String): TextView =
TextView(this).apply { TextView(this).apply {
typeface = membyTypeface()
text = message text = message
setTextColor(Color.rgb(142, 151, 157)) setTextColor(Color.rgb(142, 151, 157))
textSize = 14f textSize = 14f
@@ -4656,7 +4666,7 @@ class PlayerActivity : ComponentActivity() {
// the player is not a Compose screen — dispose with the view, not the window. // the player is not a Compose screen — dispose with the view, not the window.
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { setContent {
MembyTheme { MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
// This is where the outage bar earns its place: the stream comes // This is where the outage bar earns its place: the stream comes
// straight from Emby, so when Emby stops answering the film stalls // straight from Emby, so when Emby stops answering the film stalls
// with nothing on screen to explain it. The news bar yields the strip // with nothing on screen to explain it. The news bar yields the strip
@@ -4705,7 +4715,7 @@ class PlayerActivity : ComponentActivity() {
Color.TRANSPARENT, Color.TRANSPARENT,
CaptionStyleCompat.EDGE_TYPE_OUTLINE, CaptionStyleCompat.EDGE_TYPE_OUTLINE,
Color.BLACK, Color.BLACK,
null, membyTypeface(),
), ),
) )
setFractionalTextSize(size.fraction) setFractionalTextSize(size.fraction)
@@ -8,6 +8,7 @@ import android.graphics.RectF
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlin.math.min import kotlin.math.min
/** /**
@@ -40,7 +41,7 @@ class PrerollCountdownView @JvmOverloads constructor(
30f, 30f,
resources.displayMetrics, resources.displayMetrics,
) )
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) typeface = context.membyTypeface(android.graphics.Typeface.BOLD)
} }
private var seconds = 7 private var seconds = 7
@@ -9,6 +9,7 @@ import android.graphics.Typeface
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.min import kotlin.math.min
@@ -43,7 +44,7 @@ class SkipIntroCountdownView @JvmOverloads constructor(
private val progressPaint = Paint(trackPaint) private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER textAlign = Paint.Align.CENTER
typeface = Typeface.create("sans-serif", Typeface.BOLD) typeface = context.membyTypeface(Typeface.BOLD)
} }
private var figure = "" private var figure = ""
@@ -154,9 +154,12 @@ fun SearchScreen(
val genresEntry = remember { FocusRequester() } val genresEntry = remember { FocusRequester() }
val searchResultsEntry = remember { FocusRequester() } val searchResultsEntry = remember { FocusRequester() }
// The rail and the screen agree on one entry target: Search opens on the keyboard, val keyboardEntry = remember { FocusRequester() }
// just as browse destinations open on their primary content action. val context = LocalContext.current
val keyboardEntry = contentFocusRequester val voiceAvailable = remember { voiceSearchAvailable(context) }
// Search opens on its voice action when this television has one. The shared content
// requester is attached to that button below; without recognition support it remains
// attached to the first letter, preserving the keyboard-only fallback.
// Where "left out of the results" lands. It follows the last key used, so coming back // Where "left out of the results" lands. It follows the last key used, so coming back
// returns the viewer to where they were typing rather than to a fixed corner. // returns the viewer to where they were typing rather than to a fixed corner.
val keyboardReturn = remember { FocusRequester() } val keyboardReturn = remember { FocusRequester() }
@@ -181,7 +184,7 @@ fun SearchScreen(
(state.requestsAvailable && shouldSearch(state.query)) (state.requestsAvailable && shouldSearch(state.query))
} }
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } } LaunchedEffect(voiceAvailable) { runCatching { contentFocusRequester.requestFocus() } }
// The genre strip remains above its shelf. Once a first result exists the list takes // The genre strip remains above its shelf. Once a first result exists the list takes
// focus, which is where the viewer is already looking; an empty genre hands the remote // focus, which is where the viewer is already looking; an empty genre hands the remote
@@ -263,6 +266,8 @@ fun SearchScreen(
onBackspace = viewModel::backspace, onBackspace = viewModel::backspace,
onClear = viewModel::clearQuery, onClear = viewModel::clearQuery,
onVoiceResult = viewModel::onQueryChanged, onVoiceResult = viewModel::onQueryChanged,
voiceAvailable = voiceAvailable,
voiceFocusRequester = contentFocusRequester,
modifier = Modifier modifier = Modifier
.fillMaxWidth(KEYBOARD_PANE_FRACTION) .fillMaxWidth(KEYBOARD_PANE_FRACTION)
.fillMaxHeight(), .fillMaxHeight(),
@@ -318,6 +323,7 @@ fun SearchScreen(
onRetry = viewModel::retry, onRetry = viewModel::retry,
onShowRequests = viewModel::showRequests, onShowRequests = viewModel::showRequests,
onLoadMore = viewModel::loadMore, onLoadMore = viewModel::loadMore,
voiceAvailable = voiceAvailable,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -339,6 +345,8 @@ private fun SearchPane(
onBackspace: () -> Unit, onBackspace: () -> Unit,
onClear: () -> Unit, onClear: () -> Unit,
onVoiceResult: (String) -> Unit, onVoiceResult: (String) -> Unit,
voiceAvailable: Boolean,
voiceFocusRequester: FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -352,12 +360,21 @@ private fun SearchPane(
query = state.query, query = state.query,
loading = state.isLoading, loading = state.isLoading,
onVoiceResult = onVoiceResult, onVoiceResult = onVoiceResult,
onVoiceFocused = {
onKeyFocused(lastKeyIndex)
},
micModifier = Modifier
.focusRequester(voiceFocusRequester)
.focusProperties {
left = navigationFocusRequester
down = keyboardEntry
},
) )
Spacer(Modifier.height(14.dp)) Spacer(Modifier.height(14.dp))
TvKeyboard( TvKeyboard(
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
resultsEntry = resultsEntry, resultsEntry = resultsEntry,
keyboardEntry = keyboardEntry, keyboardEntry = if (voiceAvailable) keyboardEntry else voiceFocusRequester,
keyboardReturn = keyboardReturn, keyboardReturn = keyboardReturn,
lastKeyIndex = lastKeyIndex, lastKeyIndex = lastKeyIndex,
hasResultsTarget = hasResultsTarget, hasResultsTarget = hasResultsTarget,
@@ -365,6 +382,7 @@ private fun SearchPane(
onCharacter = onCharacter, onCharacter = onCharacter,
onBackspace = onBackspace, onBackspace = onBackspace,
onClear = onClear, onClear = onClear,
upTarget = if (voiceAvailable) voiceFocusRequester else null,
) )
} }
} }
@@ -403,6 +421,7 @@ internal fun SearchQueryField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: String = "Search movies, shows and episodes", placeholder: String = "Search movies, shows and episodes",
micModifier: Modifier = Modifier, micModifier: Modifier = Modifier,
onVoiceFocused: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val voiceAvailable = remember { voiceSearchAvailable(context) } val voiceAvailable = remember { voiceSearchAvailable(context) }
@@ -445,41 +464,12 @@ internal fun SearchQueryField(
) { startVoiceSearch() } ) { startVoiceSearch() }
Row( Row(
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(
MembyIcon.Search.mark,
contentDescription = null,
tint = if (query.isEmpty()) Muted else Accent,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = query.ifEmpty { placeholder },
color = if (query.isEmpty()) Muted else Heading,
// Fixed size rather than shrinking as the query grows: readable at three
// metres matters more than fitting a long query on one line.
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
maxLines = 1,
// A long query scrolls off the *start*, so the letters just typed stay visible.
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (loading) {
Spacer(Modifier.width(8.dp))
LoadingDot()
}
if (voiceAvailable) { if (voiceAvailable) {
Spacer(Modifier.width(8.dp))
FocusScaleContainer( FocusScaleContainer(
onFocused = {}, onFocused = onVoiceFocused,
onClick = { onClick = {
// Asked at the point of use rather than during setup: the microphone is // Asked at the point of use rather than during setup: the microphone is
// only ever wanted by this one button, and a permission dialog in front // only ever wanted by this one button, and a permission dialog in front
@@ -503,10 +493,44 @@ internal fun SearchQueryField(
tint = if (focused) KeyLabelFocused else KeyLabel, tint = if (focused) KeyLabelFocused else KeyLabel,
modifier = Modifier modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle) .background(if (focused) KeyFocused else KeyIdle)
.padding(6.dp) .padding(11.dp)
.size(18.dp), .size(20.dp),
) )
} }
Spacer(Modifier.width(10.dp))
}
Row(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
MembyIcon.Search.mark,
contentDescription = null,
tint = if (query.isEmpty()) Muted else Accent,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = query.ifEmpty { placeholder },
color = if (query.isEmpty()) Muted else Heading,
// Fixed size rather than shrinking as the query grows: readable at three
// metres matters more than fitting a long query on one line.
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
maxLines = 1,
// A long query scrolls off the *start*, so the letters just typed stay visible.
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (loading) {
Spacer(Modifier.width(8.dp))
LoadingDot()
}
} }
} }
} }
@@ -1,6 +1,13 @@
package com.ponzischeme89.memby.ui.search.components package com.ponzischeme89.memby.ui.search.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -8,10 +15,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -23,10 +32,12 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.MembyChoiceChip import com.ponzischeme89.memby.ui.MembyChoiceChip
@@ -37,8 +48,10 @@ import com.ponzischeme89.memby.ui.search.SearchUiState
import com.ponzischeme89.memby.ui.search.shouldSearch import com.ponzischeme89.memby.ui.search.shouldSearch
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
/** Search-owned result state rendered through the same media row as My Requests. */ /** Search-owned result state rendered through the same media row as My Requests. */
@@ -57,6 +70,7 @@ internal fun SearchResults(
onRetry: () -> Unit, onRetry: () -> Unit,
onShowRequests: () -> Unit, onShowRequests: () -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
voiceAvailable: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val items = state.results.filter { it.isMovie || it.isSeries } val items = state.results.filter { it.isMovie || it.isSeries }
@@ -73,15 +87,18 @@ internal fun SearchResults(
} }
} }
val showStartPrompt = state.isDiscovery && items.isEmpty()
Column(modifier) { Column(modifier) {
SearchResultsHeading( if (!showStartPrompt) {
state = state, SearchResultsHeading(
resultCount = items.size, state = state,
onShowRequests = onShowRequests, resultCount = items.size,
requestFocusRequester = entryFocusRequester, onShowRequests = onShowRequests,
keyboardReturnFocusRequester = keyboardReturnFocusRequester, requestFocusRequester = entryFocusRequester,
) keyboardReturnFocusRequester = keyboardReturnFocusRequester,
Spacer(Modifier.height(8.dp)) )
Spacer(Modifier.height(8.dp))
}
when { when {
state.isLoading && items.isEmpty() -> Column( state.isLoading && items.isEmpty() -> Column(
@@ -100,6 +117,8 @@ internal fun SearchResults(
keyboardReturnFocusRequester = keyboardReturnFocusRequester, keyboardReturnFocusRequester = keyboardReturnFocusRequester,
) )
showStartPrompt -> SearchStartPrompt(voiceAvailable)
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state)) items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
else -> LazyColumn( else -> LazyColumn(
@@ -163,6 +182,54 @@ internal fun SearchResults(
} }
} }
@Composable
private fun SearchStartPrompt(voiceAvailable: Boolean) {
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
initialValue = 0.96f,
targetValue = 1.04f,
animationSpec = infiniteRepeatable(tween(1_200), RepeatMode.Reverse),
label = "search-start-prompt-scale",
)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(54.dp)
.graphicsLayer {
scaleX = pulse.value
scaleY = pulse.value
}
.background(MembyAccent.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (voiceAvailable) MembyIcon.Mic.mark else MembyIcon.Search.mark,
contentDescription = null,
tint = MembyAccent,
modifier = Modifier.size(25.dp),
)
}
Spacer(Modifier.height(16.dp))
Text(
text = if (voiceAvailable) {
"Use your voice or type a couple of letters"
} else {
"Type a couple of letters to begin"
},
color = MembyOnSurface,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Text(
"Movies, TV series and episodes are all searchable.",
color = MembyMutedText,
fontSize = 14.sp,
)
}
}
}
@Composable @Composable
private fun SearchResultsHeading( private fun SearchResultsHeading(
state: SearchUiState, state: SearchUiState,
@@ -1115,7 +1115,6 @@ internal fun SettingsPanelContent(
} }
} }
} }
}
/** /**
* What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby * What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby
@@ -0,0 +1,27 @@
package com.ponzischeme89.memby.ui.theme
import android.content.Context
import android.graphics.Typeface
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
/** The server-selected family for Android views and custom-drawn player copy. */
internal fun Context.membyTypeface(style: Int = Typeface.NORMAL): Typeface {
if (appFontFamily(ServiceLocator.activeFontFamilyName()) != AppFontFamily.INTER) {
return Typeface.create("sans-serif", style)
}
val resource = if (style and Typeface.BOLD != 0) R.font.inter_bold else R.font.inter_regular
return ResourcesCompat.getFont(this, resource) ?: Typeface.create("sans-serif", style)
}
/** Applies the active family to an already-inflated Android view hierarchy. */
internal fun View.applyMembyTypeface() {
if (this is TextView) typeface = context.membyTypeface(typeface?.style ?: Typeface.NORMAL)
if (this is ViewGroup) {
for (index in 0 until childCount) getChildAt(index).applyMembyTypeface()
}
}
@@ -29,7 +29,7 @@ private val InterFontFamily = FontFamily(
Font(R.font.inter_bold, FontWeight.Bold), Font(R.font.inter_bold, FontWeight.Bold),
) )
private val LocalTrialFontFamily = compositionLocalOf { FontFamily.SansSerif } private val LocalAppFontFamily = compositionLocalOf<FontFamily> { FontFamily.SansSerif }
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so // The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
// a component that falls back to a theme colour lands on the surface it is sitting on // a component that falls back to a theme colour lands on the surface it is sitting on
@@ -51,38 +51,38 @@ fun MembyTheme(
fontFamilyName: String = "system", fontFamilyName: String = "system",
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
// Use Android's complete Roboto-backed sans family rather than pinning every label // The family is selected once at the root so every Compose surface agrees. System
// to sans-serif-medium. The generic family lets Compose select real regular, // keeps Android's complete Roboto-backed sans family; Inter uses bundled faces for
// medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy // each supported weight and therefore never flashes or waits on a download.
// and making body copy substantially easier to scan from TV distance. val selectedFontFamily = when (appFontFamily(fontFamilyName)) {
// AppFontFamily.SYSTEM -> FontFamily.SansSerif
// A platform family also has every script Android supports, adds no APK weight, and AppFontFamily.INTER -> InterFontFamily
// cannot flash or fail while a downloadable font is fetched. }
val appTextStyle = LocalTextStyle.current.copy( val appTextStyle = LocalTextStyle.current.copy(
fontFamily = FontFamily.SansSerif, fontFamily = selectedFontFamily,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
// Slightly open tracking and leading keep small metadata and multi-line // Slightly open tracking and leading keep small metadata and multi-line
// summaries from feeling cramped without making large headings look loose. // summaries from feeling cramped without making large headings look loose.
letterSpacing = 0.006.em, letterSpacing = 0.006.em,
lineHeight = 1.22.em, lineHeight = 1.22.em,
) )
val trialFontFamily = when (appFontFamily(fontFamilyName)) {
AppFontFamily.SYSTEM -> FontFamily.SansSerif
AppFontFamily.INTER -> InterFontFamily
}
MaterialTheme(colorScheme = embyColors()) { MaterialTheme(colorScheme = embyColors()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalTextStyle provides appTextStyle, LocalTextStyle provides appTextStyle,
LocalTrialFontFamily provides trialFontFamily, LocalAppFontFamily provides selectedFontFamily,
) { ) {
content() content()
} }
} }
} }
/** Applies the server-selected family only to the surfaces included in the visual trial. */ /**
* Compatibility boundary for the surfaces that originally took part in the font trial.
* The selected family now belongs to the root theme, so this preserves those call sites
* without limiting Inter to them.
*/
@Composable @Composable
fun MembyTrialTypography(content: @Composable () -> Unit) { fun MembyTrialTypography(content: @Composable () -> Unit) {
val textStyle = LocalTextStyle.current.copy(fontFamily = LocalTrialFontFamily.current) val textStyle = LocalTextStyle.current.copy(fontFamily = LocalAppFontFamily.current)
CompositionLocalProvider(LocalTextStyle provides textStyle, content = content) CompositionLocalProvider(LocalTextStyle provides textStyle, content = content)
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:android="http://schemas.android.com/apk/res/android">
<font android:font="@font/inter_regular" android:fontStyle="normal" android:fontWeight="400" />
<font android:font="@font/inter_medium" android:fontStyle="normal" android:fontWeight="500" />
<font android:font="@font/inter_semibold" android:fontStyle="normal" android:fontWeight="600" />
<font android:font="@font/inter_bold" android:fontStyle="normal" android:fontWeight="700" />
</font-family>
+6
View File
@@ -13,6 +13,12 @@
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
</style> </style>
<!-- Selected before PlayerActivity inflates anything, covering its XML layouts,
Media3 controller, dialogs, dynamically-created rows and custom text paints. -->
<style name="Theme.Memby.Inter.Fullscreen" parent="Theme.Memby.Fullscreen">
<item name="android:fontFamily">@font/inter_family</item>
</style>
<!-- MainActivity alone gets the local poster wall before its first Compose frame. <!-- MainActivity alone gets the local poster wall before its first Compose frame.
Player and screensaver windows keep their black background, where a launch poster Player and screensaver windows keep their black background, where a launch poster
appearing behind a video surface would be a flash rather than a welcome. --> appearing behind a video surface would be a flash rather than a welcome. -->
@@ -7,7 +7,7 @@ import org.junit.Test
class MediaBadgesTest { class MediaBadgesTest {
@Test @Test
fun `derives premium video and audio badges without duplicates`() { fun `derives viewer-facing picture and audio badges without codec labels`() {
val item = BaseItem( val item = BaseItem(
id = "movie", id = "movie",
mediaStreams = listOf( mediaStreams = listOf(
@@ -23,13 +23,13 @@ class MediaBadgesTest {
) )
assertEquals( assertEquals(
listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"), listOf("4K", "DOLBY VISION", "DOLBY ATMOS"),
mediaBadges(item), mediaBadges(item),
) )
} }
@Test @Test
fun `includes surround and non-surround sound profiles`() { fun `includes supported surround profiles and omits other channel counts`() {
fun badgesFor(channels: Int) = mediaBadges( fun badgesFor(channels: Int) = mediaBadges(
BaseItem( BaseItem(
id = "movie-$channels", id = "movie-$channels",
@@ -37,8 +37,8 @@ class MediaBadgesTest {
), ),
) )
assertEquals(listOf("MONO"), badgesFor(1)) assertEquals(emptyList<String>(), badgesFor(1))
assertEquals(listOf("STEREO"), badgesFor(2)) assertEquals(emptyList<String>(), badgesFor(2))
assertEquals(listOf("5.1"), badgesFor(6)) assertEquals(listOf("5.1"), badgesFor(6))
assertEquals(listOf("7.1"), badgesFor(8)) assertEquals(listOf("7.1"), badgesFor(8))
} }
@@ -55,7 +55,7 @@ class MetadataHeroOrderTest {
) )
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item)) assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
assertEquals(listOf("STEREO"), mediaBadges(item)) assertEquals(emptyList<String>(), mediaBadges(item))
} }
@Test @Test
+51 -11
View File
@@ -28,6 +28,11 @@ by this script.
This deploys the current local working tree, including uncommitted server changes. This deploys the current local working tree, including uncommitted server changes.
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published. Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
Use -Fast for a quicker full deployment. It skips Android unit and screenshot tests and
reuses cached Docker dependency/base images when available. The signed APK build and
verification, container builds, activation, rollback, release publishing and health checks
still run.
Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The
console is its own container with its own health check and nothing depends on it, so console is its own container with its own health check and nothing depends on it, so
only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one
@@ -57,6 +62,9 @@ deployment and refuses to create one.
.EXAMPLE .EXAMPLE
.\deploy-server.ps1 -SkipAppRelease .\deploy-server.ps1 -SkipAppRelease
.EXAMPLE
.\deploy-server.ps1 -Fast
.EXAMPLE .EXAMPLE
.\deploy-server.ps1 --Admin .\deploy-server.ps1 --Admin
@@ -98,6 +106,9 @@ param(
[Parameter()] [Parameter()]
[switch] $SkipAppTests, [switch] $SkipAppTests,
[Parameter()]
[switch] $Fast,
[Parameter()] [Parameter()]
[switch] $SkipAppRelease, [switch] $SkipAppRelease,
@@ -137,7 +148,7 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$unknownFlags = @($trailingFlags | Where-Object { $unknownFlags = @($trailingFlags | Where-Object {
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin') $_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin', '--Fast', '--fast')
}) })
if ($unknownFlags.Count -gt 0) { if ($unknownFlags.Count -gt 0) {
throw "Unknown deployment option: $($unknownFlags -join ', ')" throw "Unknown deployment option: $($unknownFlags -join ', ')"
@@ -145,6 +156,15 @@ if ($unknownFlags.Count -gt 0) {
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m' $mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet' $quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin' $consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
$fastDeployment = $Fast -or $trailingFlags -contains '--Fast' -or $trailingFlags -contains '--fast'
if ($fastDeployment) {
# Screenshot tests are ordinary testDebugUnitTest classes in this repository, so the
# only reliable way to keep them out of a deployment build is to omit that Gradle task.
# assembleRelease still compiles the production app and the checks below still prove
# that the APK is signed and carries the requested version.
$SkipAppTests = $true
}
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) { if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.' throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
@@ -164,7 +184,7 @@ if ($consoleOnly) {
# measured twice: a console run rebuilds one small image and a full one swaps the whole # measured twice: a console run rebuilds one small image and a full one swaps the whole
# stack. Averaged together, each estimate would be wrong for both. A record written before # stack. Averaged together, each estimate would be wrong for both. A record written before
# this existed carries no kind and is read as 'full', which is what all of them were. # this existed carries no kind and is read as 'full', which is what all of them were.
$script:DeploymentKind = if ($consoleOnly) { 'console' } else { 'full' } $script:DeploymentKind = if ($consoleOnly) { 'console' } elseif ($fastDeployment) { 'fast' } else { 'full' }
$script:CurrentStep = 0 $script:CurrentStep = 0
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 } $script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
@@ -184,11 +204,11 @@ $script:FallbackSeconds = @{
'prerequisites' = 3 'prerequisites' = 3
'source' = 2 'source' = 2
'payload' = 4 'payload' = 4
'android-release' = 230 'android-release' = if ($fastDeployment) { 150 } else { 230 }
'packaging' = 12 'packaging' = 12
# A console deployment uploads one small build context and rebuilds one image, where a # A console deployment uploads one small build context and rebuilds one image, where a
# full one ships the Go tree as well and rebuilds the whole stack. # full one ships the Go tree as well and rebuilds the whole stack.
'remote-deployment' = if ($consoleOnly) { 90 } else { 330 } 'remote-deployment' = if ($consoleOnly) { 90 } elseif ($fastDeployment) { 270 } else { 330 }
} }
$script:PhaseDurations = [ordered]@{} $script:PhaseDurations = [ordered]@{}
$script:CurrentPhaseKey = $null $script:CurrentPhaseKey = $null
@@ -435,7 +455,7 @@ function Show-DeploymentEstimate {
'prerequisites' = 'Local prerequisites' 'prerequisites' = 'Local prerequisites'
'source' = 'Source selection' 'source' = 'Source selection'
'payload' = 'Compose validation' 'payload' = 'Compose validation'
'android-release' = 'Android build and tests' 'android-release' = if ($fastDeployment) { 'Android release build' } else { 'Android build and tests' }
'packaging' = 'Release packaging' 'packaging' = 'Release packaging'
'remote-deployment' = 'Upload, remote build and activation' 'remote-deployment' = 'Upload, remote build and activation'
} }
@@ -471,6 +491,9 @@ function Write-Banner {
if ($quietDeployment) { if ($quietDeployment) {
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
} }
if ($fastDeployment) {
Write-Styled -Message '│ MODE fast (tests and forced image refresh skipped)' -Colour Yellow
}
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } | $initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
Measure-Object -Sum).Sum Measure-Object -Sum).Sum
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples }) $sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
@@ -901,6 +924,10 @@ try {
$gradleArguments = @('--console=plain') $gradleArguments = @('--console=plain')
if (-not $SkipAppTests) { if (-not $SkipAppTests) {
$gradleArguments += 'testDebugUnitTest' $gradleArguments += 'testDebugUnitTest'
} elseif ($fastDeployment) {
Write-Detail 'Fast mode: skipping Android unit and screenshot tests'
} else {
Write-Detail 'Android tests skipped by -SkipAppTests'
} }
$gradleArguments += @( $gradleArguments += @(
'assembleRelease', 'assembleRelease',
@@ -1188,6 +1215,7 @@ health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__ publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__ mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__ quiet_deployment=__QUIET_DEPLOYMENT__
fast_deployment=__FAST_DEPLOYMENT__
colour_output=__COLOUR_OUTPUT__ colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__ remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__ total_steps=__TOTAL_STEPS__
@@ -1423,11 +1451,15 @@ else
fi fi
step 'Pulling PostgreSQL and Redis' step 'Pulling PostgreSQL and Redis'
( if [ "$fast_deployment" -eq 1 ]; then
cd "$staging" detail 'Fast mode; reusing cached dependency images when available'
docker compose pull postgres redis else
) (
success 'Dependency images are ready' cd "$staging"
docker compose pull postgres redis
)
success 'Dependency images are ready'
fi
step 'Building the gateway and admin console' step 'Building the gateway and admin console'
( (
@@ -1435,7 +1467,11 @@ step 'Building the gateway and admin console'
# `up` reuses an existing image when one is present. Build both local contexts here, # `up` reuses an existing image when one is present. Build both local contexts here,
# otherwise a new React/nginx console can be packaged and activated while the NAS # otherwise a new React/nginx console can be packaged and activated while the NAS
# continues to serve the previous console image (and its old route configuration). # continues to serve the previous console image (and its old route configuration).
docker compose build --pull server memby-admin if [ "$fast_deployment" -eq 1 ]; then
docker compose build server memby-admin
else
docker compose build --pull server memby-admin
fi
) )
success 'Gateway and admin console images built' success 'Gateway and admin console images built'
@@ -1568,6 +1604,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
'__QUIET_DEPLOYMENT__', '__QUIET_DEPLOYMENT__',
$(if ($quietDeployment) { '1' } else { '0' }) $(if ($quietDeployment) { '1' } else { '0' })
) )
$remoteCommand = $remoteCommand.Replace(
'__FAST_DEPLOYMENT__',
$(if ($fastDeployment) { '1' } else { '0' })
)
$remoteCommand = $remoteCommand.Replace( $remoteCommand = $remoteCommand.Replace(
'__COLOUR_OUTPUT__', '__COLOUR_OUTPUT__',
$(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' }) $(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })