diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ba99f33..e45e059 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,7 +62,7 @@ val projectNoticeText = rootProject.file("NOTICE").readText() .replace("https://g.sublogue.com/admin/memby", membySourceUrl) -val defaultVersionName = "0.3.03" +val defaultVersionName = "0.3.04" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index cfaa17e..96ed6a2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -34,6 +34,10 @@ object ServiceLocator { lateinit var remoteConfig: RemoteConfigManager 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 * anything calls: it starts working when it is constructed and must not be collected diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index faeefa9..a9ed4c0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -1208,8 +1208,8 @@ internal fun DetailFactRow( if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp) 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 - // the airing badge appended after them, which is the one that is news. + // Four, not three: picture, audio and airing metadata can all share this line, and + // the airing badge appended after them is the one that is news. badges.take(4).forEach { badge -> Spacer(Modifier.width(8.dp)) MediaBadge(badge) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 6e45ba4..b84774a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -386,7 +387,16 @@ private fun FeaturedMovieCard( contentDescription = "Featured, ${pick.label}, ${item.name}", modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), ) { 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()) Box( Modifier.fillMaxSize().background( @@ -539,7 +549,16 @@ private fun MiniMovieCard( contentDescription = "${pick.label} movie, ${item.name}", modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), ) { 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()) Box(Modifier.fillMaxSize().background(labelTint(pick.label))) Box( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt index 993bf44..8138001 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/TvSettingsComponents.kt @@ -25,6 +25,7 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOutline import com.ponzischeme89.memby.ui.theme.MembyPanelCorner 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 @@ -170,7 +171,7 @@ fun tvSettingsOptionView( val title = TextView(context).apply { text = label textSize = if (compact) 14f else 16f - typeface = Typeface.create("sans-serif", Typeface.BOLD) + typeface = context.membyTypeface(Typeface.BOLD) maxLines = 1 } copy.addView(title) @@ -178,7 +179,7 @@ fun tvSettingsOptionView( TextView(context).apply { this.text = text textSize = 12f - typeface = Typeface.create("sans-serif", Typeface.NORMAL) + typeface = context.membyTypeface() maxLines = 2 copy.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = context.dp(2) @@ -190,7 +191,7 @@ fun tvSettingsOptionView( val trailing = TextView(context).apply { text = if (selected) "CURRENT" else value textSize = if (selected) 10f else 12f - typeface = Typeface.create("sans-serif", Typeface.BOLD) + typeface = context.membyTypeface(Typeface.BOLD) gravity = Gravity.CENTER letterSpacing = if (selected) 0.08f else 0f visibility = if (text.isBlank()) View.GONE else View.VISIBLE @@ -271,14 +272,14 @@ private fun tvSettingsPanel(context: Context, title: String, description: String text = title setTextColor(MembyOnSurface.toArgb()) textSize = 22f - typeface = Typeface.create("sans-serif", Typeface.BOLD) + typeface = context.membyTypeface(Typeface.BOLD) }) if (description.isNotBlank()) { root.addView(TextView(context).apply { text = description setTextColor(MembyMutedText.toArgb()) textSize = 14f - typeface = Typeface.create("sans-serif", Typeface.NORMAL) + typeface = context.membyTypeface() }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = context.dp(5) }) @@ -334,7 +335,7 @@ private fun tvSettingsAction(context: Context, label: String, primary: Boolean, val button = TextView(context).apply { text = label textSize = 14f - typeface = Typeface.create("sans-serif", Typeface.BOLD) + typeface = context.membyTypeface(Typeface.BOLD) gravity = Gravity.CENTER isFocusable = true isClickable = true diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt index 82f0c9a..6f5b5d3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt @@ -96,7 +96,6 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel 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.formatRuntime import com.ponzischeme89.memby.ui.theme.FactSeparator @@ -143,6 +142,23 @@ fun MediaBadge( fontSize: TextUnit = 11.sp, 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( label, color = MembyOnSurface, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt index b0d6c43..9b429ae 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt @@ -95,7 +95,6 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel 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.formatRuntime import com.ponzischeme89.memby.ui.theme.FactSeparator @@ -202,9 +201,11 @@ internal fun mediaBadges(item: BaseItem): List { // HDR10+ in one place and plain HDR in the other. dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title) ?.let { add(it.uppercase(Locale.US)) } - if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC") - audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) } - if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS") + when { + audio?.title?.contains("atmos", true) == true -> add("DOLBY ATMOS") + audio?.channels == 8 -> add("7.1") + audio?.channels == 6 -> add("5.1") + } }.distinct() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt index e5a06f4..86b339e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeMetadataPanel.kt @@ -31,8 +31,11 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale 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.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.sp @@ -237,13 +240,19 @@ private fun MetadataContent( ) { metadataHeroContentOrder(contentOrder).forEach { 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( item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(), ) MetadataHeroSection.Facts -> { // 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 // file information wraps or escapes the hero. if (facts.isNotEmpty() || badges.isNotEmpty()) { @@ -285,12 +294,48 @@ private fun MetadataContent( colour = timeRemainingColour, isContinueWatchingItem = isContinueWatchingItem, ) - MetadataHeroSection.Summary -> Text( - item.overview?.takeIf(String::isNotBlank) ?: "No description available.", - color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp, - maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis, + MetadataHeroSection.Summary -> Column( + verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), 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) @@ -328,7 +373,13 @@ internal fun metadataHeroFacts(item: BaseItem): List = buildList { } @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( modifier = Modifier.fillMaxWidth().then( 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) } } - if (item.isEpisode) { + if (item.isEpisode && showEpisodeLabel) { episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label -> Text( label, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt index 8f8fa7d..d1897f2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt @@ -125,8 +125,6 @@ enum class BrowseDestination(val label: String, val icon: MembyIcon) { MOVIES("Movies", MembyIcon.Movie), SHOWS("TV Shows", MembyIcon.Tv), 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), FAVORITES("Favourites", MembyIcon.Favourite), PROFILES("User", MembyIcon.Person), @@ -251,7 +249,7 @@ fun TvNavigationRail( model = markPath, contentDescription = "Memby", modifier = Modifier - .size(30.dp) + .size(34.dp) .graphicsLayer { scaleX = logoScale.value scaleY = logoScale.value @@ -261,7 +259,7 @@ fun TvNavigationRail( ) else Image( painter = painterResource(R.drawable.memby_mark), 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 }, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt index 82ffb07..8d74bff 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CastPanel.kt @@ -14,6 +14,7 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.core.view.isVisible 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. */ data class CastMember( @@ -141,10 +142,10 @@ private fun castCard( ) setPadding(0, dp(9), 0, 0) addView(TextView(context).apply { + typeface = context.membyTypeface(Typeface.BOLD) text = member.name setTextColor(Color.WHITE) textSize = 14f - typeface = Typeface.create("sans-serif", Typeface.BOLD) maxLines = 1 ellipsize = TextUtils.TruncateAt.END }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) @@ -159,6 +160,7 @@ private fun castCard( member.role?.takeIf(String::isNotBlank)?.let { role -> addView( TextView(context).apply { + typeface = context.membyTypeface() text = role setTextColor(Color.rgb(158, 168, 178)) textSize = 11f @@ -182,6 +184,7 @@ private fun bindPersonProfile( val life = personLifeDates(person.birthDate, person.deathDate) if (life.isNotBlank() || !person.role.isNullOrBlank()) { container.addView(TextView(context).apply { + typeface = context.membyTypeface() text = listOfNotNull( person.role?.takeIf(String::isNotBlank), life.takeIf(String::isNotBlank), @@ -193,6 +196,7 @@ private fun bindPersonProfile( }) } container.addView(TextView(context).apply { + typeface = context.membyTypeface() text = person.overview?.takeIf(String::isNotBlank) ?: context.getString(R.string.player_cast_biography_empty) setTextColor(Color.WHITE) @@ -203,15 +207,16 @@ private fun bindPersonProfile( setPadding(0, dp(7), 0, 0) }) container.addView(TextView(context).apply { + typeface = context.membyTypeface(Typeface.BOLD) text = context.getString(R.string.player_cast_filmography) setTextColor(Color.rgb(82, 181, 75)) textSize = 11f - typeface = Typeface.create("sans-serif", Typeface.BOLD) letterSpacing = 0.12f setPadding(0, dp(13), 0, dp(5)) }) if (person.filmography.isEmpty()) { container.addView(TextView(context).apply { + typeface = context.membyTypeface() text = context.getString(R.string.player_cast_filmography_empty) setTextColor(Color.rgb(185, 193, 200)) textSize = 13f @@ -271,15 +276,16 @@ private fun filmographyCard( addView(LinearLayout(context).apply { orientation = LinearLayout.VERTICAL addView(TextView(context).apply { + typeface = context.membyTypeface(Typeface.BOLD) text = credit.title setTextColor(Color.WHITE) textSize = 12f - typeface = Typeface.create("sans-serif", Typeface.BOLD) 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 @@ -333,6 +339,7 @@ private fun castPortrait( addView( TextView(context).apply { + typeface = context.membyTypeface() layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, @@ -340,7 +347,6 @@ private fun castPortrait( text = castInitials(member.name) setTextColor(Color.rgb(126, 138, 148)) textSize = 34f - typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) gravity = Gravity.CENTER }, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index d651d95..9f004e7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -104,7 +104,11 @@ import com.ponzischeme89.memby.ui.components.TvSettingsMenuOption import com.ponzischeme89.memby.ui.components.showTvSettingsMenu import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu 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.applyMembyTypeface +import com.ponzischeme89.memby.ui.theme.appFontFamily +import com.ponzischeme89.memby.ui.theme.membyTypeface import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async @@ -539,6 +543,9 @@ class PlayerActivity : ComponentActivity() { @UnstableApi override fun onCreate(savedInstanceState: Bundle?) { + if (appFontFamily(ServiceLocator.remoteConfig.active.presentation.fontFamily) == AppFontFamily.INTER) { + setTheme(R.style.Theme_Memby_Inter_Fullscreen) + } super.onCreate(savedInstanceState) 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}") setContentView(R.layout.activity_player) + findViewById(android.R.id.content).applyMembyTypeface() val enhancementsAvailable = membyPlayerEnhancementsAvailable( playingTrailer = playingTrailer, ) @@ -1764,6 +1772,7 @@ class PlayerActivity : ComponentActivity() { private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View = LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply { + applyMembyTypeface() findViewById(R.id.player_preroll_card_label).text = label findViewById(R.id.player_preroll_card_title).text = entry.series findViewById(R.id.player_preroll_card_detail).text = listOfNotNull( @@ -1784,6 +1793,7 @@ class PlayerActivity : ComponentActivity() { private fun prerollMessage(message: String): TextView = TextView(this).apply { + typeface = membyTypeface() text = message setTextColor(Color.rgb(142, 151, 157)) textSize = 14f @@ -4656,7 +4666,7 @@ class PlayerActivity : ComponentActivity() { // the player is not a Compose screen — dispose with the view, not the window. setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - MembyTheme { + MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) { // This is where the outage bar earns its place: the stream comes // straight from Emby, so when Emby stops answering the film stalls // with nothing on screen to explain it. The news bar yields the strip @@ -4705,7 +4715,7 @@ class PlayerActivity : ComponentActivity() { Color.TRANSPARENT, CaptionStyleCompat.EDGE_TYPE_OUTLINE, Color.BLACK, - null, + membyTypeface(), ), ) setFractionalTextSize(size.fraction) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt index a36134e..32b29ba 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollCountdownView.kt @@ -8,6 +8,7 @@ import android.graphics.RectF import android.util.AttributeSet import android.util.TypedValue import android.view.View +import com.ponzischeme89.memby.ui.theme.membyTypeface import kotlin.math.min /** @@ -40,7 +41,7 @@ class PrerollCountdownView @JvmOverloads constructor( 30f, resources.displayMetrics, ) - typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + typeface = context.membyTypeface(android.graphics.Typeface.BOLD) } private var seconds = 7 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt index f550176..c823008 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt @@ -9,6 +9,7 @@ import android.graphics.Typeface import android.util.AttributeSet import android.view.View import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.ui.theme.membyTypeface import kotlin.math.ceil import kotlin.math.min @@ -43,7 +44,7 @@ class SkipIntroCountdownView @JvmOverloads constructor( private val progressPaint = Paint(trackPaint) private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER - typeface = Typeface.create("sans-serif", Typeface.BOLD) + typeface = context.membyTypeface(Typeface.BOLD) } private var figure = "" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 44282a8..6ec5e6c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -154,9 +154,12 @@ fun SearchScreen( val genresEntry = remember { FocusRequester() } val searchResultsEntry = remember { FocusRequester() } - // The rail and the screen agree on one entry target: Search opens on the keyboard, - // just as browse destinations open on their primary content action. - val keyboardEntry = contentFocusRequester + val keyboardEntry = remember { FocusRequester() } + val context = LocalContext.current + 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 // returns the viewer to where they were typing rather than to a fixed corner. val keyboardReturn = remember { FocusRequester() } @@ -181,7 +184,7 @@ fun SearchScreen( (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 // focus, which is where the viewer is already looking; an empty genre hands the remote @@ -263,6 +266,8 @@ fun SearchScreen( onBackspace = viewModel::backspace, onClear = viewModel::clearQuery, onVoiceResult = viewModel::onQueryChanged, + voiceAvailable = voiceAvailable, + voiceFocusRequester = contentFocusRequester, modifier = Modifier .fillMaxWidth(KEYBOARD_PANE_FRACTION) .fillMaxHeight(), @@ -318,6 +323,7 @@ fun SearchScreen( onRetry = viewModel::retry, onShowRequests = viewModel::showRequests, onLoadMore = viewModel::loadMore, + voiceAvailable = voiceAvailable, modifier = Modifier.weight(1f), ) } @@ -339,6 +345,8 @@ private fun SearchPane( onBackspace: () -> Unit, onClear: () -> Unit, onVoiceResult: (String) -> Unit, + voiceAvailable: Boolean, + voiceFocusRequester: FocusRequester, modifier: Modifier = Modifier, ) { Column( @@ -352,12 +360,21 @@ private fun SearchPane( query = state.query, loading = state.isLoading, onVoiceResult = onVoiceResult, + onVoiceFocused = { + onKeyFocused(lastKeyIndex) + }, + micModifier = Modifier + .focusRequester(voiceFocusRequester) + .focusProperties { + left = navigationFocusRequester + down = keyboardEntry + }, ) Spacer(Modifier.height(14.dp)) TvKeyboard( navigationFocusRequester = navigationFocusRequester, resultsEntry = resultsEntry, - keyboardEntry = keyboardEntry, + keyboardEntry = if (voiceAvailable) keyboardEntry else voiceFocusRequester, keyboardReturn = keyboardReturn, lastKeyIndex = lastKeyIndex, hasResultsTarget = hasResultsTarget, @@ -365,6 +382,7 @@ private fun SearchPane( onCharacter = onCharacter, onBackspace = onBackspace, onClear = onClear, + upTarget = if (voiceAvailable) voiceFocusRequester else null, ) } } @@ -403,6 +421,7 @@ internal fun SearchQueryField( modifier: Modifier = Modifier, placeholder: String = "Search movies, shows and episodes", micModifier: Modifier = Modifier, + onVoiceFocused: () -> Unit = {}, ) { val context = LocalContext.current val voiceAvailable = remember { voiceSearchAvailable(context) } @@ -445,41 +464,12 @@ internal fun SearchQueryField( ) { startVoiceSearch() } Row( - modifier = modifier - .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), + modifier = modifier.fillMaxWidth(), 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) { - Spacer(Modifier.width(8.dp)) FocusScaleContainer( - onFocused = {}, + onFocused = onVoiceFocused, onClick = { // 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 @@ -503,10 +493,44 @@ internal fun SearchQueryField( tint = if (focused) KeyLabelFocused else KeyLabel, modifier = Modifier .background(if (focused) KeyFocused else KeyIdle) - .padding(6.dp) - .size(18.dp), + .padding(11.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() + } } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt index 6995069..1caae7c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt @@ -1,6 +1,13 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer 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.sp +import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.data.model.BaseItem 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.theme.FactSeparator 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.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.mark import kotlinx.coroutines.flow.distinctUntilChanged /** Search-owned result state rendered through the same media row as My Requests. */ @@ -57,6 +70,7 @@ internal fun SearchResults( onRetry: () -> Unit, onShowRequests: () -> Unit, onLoadMore: () -> Unit, + voiceAvailable: Boolean, modifier: Modifier = Modifier, ) { val items = state.results.filter { it.isMovie || it.isSeries } @@ -73,15 +87,18 @@ internal fun SearchResults( } } + val showStartPrompt = state.isDiscovery && items.isEmpty() Column(modifier) { - SearchResultsHeading( - state = state, - resultCount = items.size, - onShowRequests = onShowRequests, - requestFocusRequester = entryFocusRequester, - keyboardReturnFocusRequester = keyboardReturnFocusRequester, - ) - Spacer(Modifier.height(8.dp)) + if (!showStartPrompt) { + SearchResultsHeading( + state = state, + resultCount = items.size, + onShowRequests = onShowRequests, + requestFocusRequester = entryFocusRequester, + keyboardReturnFocusRequester = keyboardReturnFocusRequester, + ) + Spacer(Modifier.height(8.dp)) + } when { state.isLoading && items.isEmpty() -> Column( @@ -100,6 +117,8 @@ internal fun SearchResults( keyboardReturnFocusRequester = keyboardReturnFocusRequester, ) + showStartPrompt -> SearchStartPrompt(voiceAvailable) + items.isEmpty() -> SearchResultMessage(message = emptyMessage(state)) 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 private fun SearchResultsHeading( state: SearchUiState, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 5d2e770..2eed5d7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -1115,7 +1115,6 @@ internal fun SettingsPanelContent( } } } -} /** * What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/AppTypeface.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/AppTypeface.kt new file mode 100644 index 0000000..b3b0f4b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/AppTypeface.kt @@ -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() + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt index 0498dab..6a313c1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt @@ -29,7 +29,7 @@ private val InterFontFamily = FontFamily( Font(R.font.inter_bold, FontWeight.Bold), ) -private val LocalTrialFontFamily = compositionLocalOf { FontFamily.SansSerif } +private val LocalAppFontFamily = compositionLocalOf { FontFamily.SansSerif } // 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 @@ -51,38 +51,38 @@ fun MembyTheme( fontFamilyName: String = "system", content: @Composable () -> Unit, ) { - // Use Android's complete Roboto-backed sans family rather than pinning every label - // to sans-serif-medium. The generic family lets Compose select real regular, - // medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy - // and making body copy substantially easier to scan from TV distance. - // - // A platform family also has every script Android supports, adds no APK weight, and - // cannot flash or fail while a downloadable font is fetched. + // The family is selected once at the root so every Compose surface agrees. System + // keeps Android's complete Roboto-backed sans family; Inter uses bundled faces for + // each supported weight and therefore never flashes or waits on a download. + val selectedFontFamily = when (appFontFamily(fontFamilyName)) { + AppFontFamily.SYSTEM -> FontFamily.SansSerif + AppFontFamily.INTER -> InterFontFamily + } val appTextStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.SansSerif, + fontFamily = selectedFontFamily, fontWeight = FontWeight.Normal, // Slightly open tracking and leading keep small metadata and multi-line // summaries from feeling cramped without making large headings look loose. letterSpacing = 0.006.em, lineHeight = 1.22.em, ) - val trialFontFamily = when (appFontFamily(fontFamilyName)) { - AppFontFamily.SYSTEM -> FontFamily.SansSerif - AppFontFamily.INTER -> InterFontFamily - } MaterialTheme(colorScheme = embyColors()) { CompositionLocalProvider( LocalTextStyle provides appTextStyle, - LocalTrialFontFamily provides trialFontFamily, + LocalAppFontFamily provides selectedFontFamily, ) { 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 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) } diff --git a/app/src/main/res/drawable-nodpi/icon_surround_sound.png b/app/src/main/res/drawable-nodpi/icon_surround_sound.png new file mode 100644 index 0000000..ab4ea7c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/icon_surround_sound.png differ diff --git a/app/src/main/res/font/inter_family.xml b/app/src/main/res/font/inter_family.xml new file mode 100644 index 0000000..54fba9c --- /dev/null +++ b/app/src/main/res/font/inter_family.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f4d0bcd..3246dbb 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -13,6 +13,12 @@ true + + + diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt index 08a1ae4..b3f4c82 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class MediaBadgesTest { @Test - fun `derives premium video and audio badges without duplicates`() { + fun `derives viewer-facing picture and audio badges without codec labels`() { val item = BaseItem( id = "movie", mediaStreams = listOf( @@ -23,13 +23,13 @@ class MediaBadgesTest { ) assertEquals( - listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"), + listOf("4K", "DOLBY VISION", "DOLBY ATMOS"), mediaBadges(item), ) } @Test - fun `includes surround and non-surround sound profiles`() { + fun `includes supported surround profiles and omits other channel counts`() { fun badgesFor(channels: Int) = mediaBadges( BaseItem( id = "movie-$channels", @@ -37,8 +37,8 @@ class MediaBadgesTest { ), ) - assertEquals(listOf("MONO"), badgesFor(1)) - assertEquals(listOf("STEREO"), badgesFor(2)) + assertEquals(emptyList(), badgesFor(1)) + assertEquals(emptyList(), badgesFor(2)) assertEquals(listOf("5.1"), badgesFor(6)) assertEquals(listOf("7.1"), badgesFor(8)) } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt index a75d6f4..80851b5 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MetadataHeroOrderTest.kt @@ -55,7 +55,7 @@ class MetadataHeroOrderTest { ) assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item)) - assertEquals(listOf("STEREO"), mediaBadges(item)) + assertEquals(emptyList(), mediaBadges(item)) } @Test diff --git a/deploy-server.ps1 b/deploy-server.ps1 index cf97c7c..f0c5ef1 100644 --- a/deploy-server.ps1 +++ b/deploy-server.ps1 @@ -28,6 +28,11 @@ by this script. 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 -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 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 @@ -57,6 +62,9 @@ deployment and refuses to create one. .EXAMPLE .\deploy-server.ps1 -SkipAppRelease +.EXAMPLE +.\deploy-server.ps1 -Fast + .EXAMPLE .\deploy-server.ps1 --Admin @@ -98,6 +106,9 @@ param( [Parameter()] [switch] $SkipAppTests, + [Parameter()] + [switch] $Fast, + [Parameter()] [switch] $SkipAppRelease, @@ -137,7 +148,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $unknownFlags = @($trailingFlags | Where-Object { - $_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin') + $_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin', '--Fast', '--fast') }) if ($unknownFlags.Count -gt 0) { throw "Unknown deployment option: $($unknownFlags -join ', ')" @@ -145,6 +156,15 @@ if ($unknownFlags.Count -gt 0) { $mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m' $quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet' $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)) { 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 # 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. -$script:DeploymentKind = if ($consoleOnly) { 'console' } else { 'full' } +$script:DeploymentKind = if ($consoleOnly) { 'console' } elseif ($fastDeployment) { 'fast' } else { 'full' } $script:CurrentStep = 0 $script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 } @@ -184,11 +204,11 @@ $script:FallbackSeconds = @{ 'prerequisites' = 3 'source' = 2 'payload' = 4 - 'android-release' = 230 + 'android-release' = if ($fastDeployment) { 150 } else { 230 } 'packaging' = 12 # 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. - 'remote-deployment' = if ($consoleOnly) { 90 } else { 330 } + 'remote-deployment' = if ($consoleOnly) { 90 } elseif ($fastDeployment) { 270 } else { 330 } } $script:PhaseDurations = [ordered]@{} $script:CurrentPhaseKey = $null @@ -435,7 +455,7 @@ function Show-DeploymentEstimate { 'prerequisites' = 'Local prerequisites' 'source' = 'Source selection' 'payload' = 'Compose validation' - 'android-release' = 'Android build and tests' + 'android-release' = if ($fastDeployment) { 'Android release build' } else { 'Android build and tests' } 'packaging' = 'Release packaging' 'remote-deployment' = 'Upload, remote build and activation' } @@ -471,6 +491,9 @@ function Write-Banner { if ($quietDeployment) { 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 } | Measure-Object -Sum).Sum $sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples }) @@ -901,6 +924,10 @@ try { $gradleArguments = @('--console=plain') if (-not $SkipAppTests) { $gradleArguments += 'testDebugUnitTest' + } elseif ($fastDeployment) { + Write-Detail 'Fast mode: skipping Android unit and screenshot tests' + } else { + Write-Detail 'Android tests skipped by -SkipAppTests' } $gradleArguments += @( 'assembleRelease', @@ -1188,6 +1215,7 @@ health_timeout=__HEALTH_TIMEOUT__ publish_release=__PUBLISH_RELEASE__ mandatory_update=__MANDATORY_UPDATE__ quiet_deployment=__QUIET_DEPLOYMENT__ +fast_deployment=__FAST_DEPLOYMENT__ colour_output=__COLOUR_OUTPUT__ remote_step_offset=__REMOTE_STEP_OFFSET__ total_steps=__TOTAL_STEPS__ @@ -1423,11 +1451,15 @@ else fi step 'Pulling PostgreSQL and Redis' -( - cd "$staging" - docker compose pull postgres redis -) -success 'Dependency images are ready' +if [ "$fast_deployment" -eq 1 ]; then + detail 'Fast mode; reusing cached dependency images when available' +else + ( + cd "$staging" + docker compose pull postgres redis + ) + success 'Dependency images are ready' +fi 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, # 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). - 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' @@ -1568,6 +1604,10 @@ success 'Memby gateway: https://mserver.sublogue.com' '__QUIET_DEPLOYMENT__', $(if ($quietDeployment) { '1' } else { '0' }) ) + $remoteCommand = $remoteCommand.Replace( + '__FAST_DEPLOYMENT__', + $(if ($fastDeployment) { '1' } else { '0' }) + ) $remoteCommand = $remoteCommand.Replace( '__COLOUR_OUTPUT__', $(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })