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()
.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()
@@ -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
@@ -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)
@@ -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(
@@ -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
@@ -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,
@@ -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<String> {
// 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()
}
@@ -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,13 +294,49 @@ 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<String> = 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,
@@ -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
},
)
@@ -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
},
)
@@ -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<View>(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<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_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)
@@ -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
@@ -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 = ""
@@ -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,8 +464,44 @@ internal fun SearchQueryField(
) { startVoiceSearch() }
Row(
modifier = modifier
.fillMaxWidth()
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (voiceAvailable) {
FocusScaleContainer(
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
// of a new television is one nobody can connect to anything.
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
startVoiceSearch()
} else {
runCatching { micPermission.launch(Manifest.permission.RECORD_AUDIO) }
}
},
contentDescription = "Search by voice",
modifier = micModifier.clip(RoundedCornerShape(8.dp)),
) { focused ->
Icon(
MembyIcon.Mic.mark,
contentDescription = null,
tint = if (focused) KeyLabelFocused else KeyLabel,
modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle)
.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))
@@ -476,37 +531,6 @@ internal fun SearchQueryField(
Spacer(Modifier.width(8.dp))
LoadingDot()
}
if (voiceAvailable) {
Spacer(Modifier.width(8.dp))
FocusScaleContainer(
onFocused = {},
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
// of a new television is one nobody can connect to anything.
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
startVoiceSearch()
} else {
runCatching { micPermission.launch(Manifest.permission.RECORD_AUDIO) }
}
},
contentDescription = "Search by voice",
modifier = micModifier.clip(RoundedCornerShape(8.dp)),
) { focused ->
Icon(
MembyIcon.Mic.mark,
contentDescription = null,
tint = if (focused) KeyLabelFocused else KeyLabel,
modifier = Modifier
.background(if (focused) KeyFocused else KeyIdle)
.padding(6.dp)
.size(18.dp),
)
}
}
}
}
@@ -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,7 +87,9 @@ internal fun SearchResults(
}
}
val showStartPrompt = state.isDiscovery && items.isEmpty()
Column(modifier) {
if (!showStartPrompt) {
SearchResultsHeading(
state = state,
resultCount = items.size,
@@ -82,6 +98,7 @@ internal fun SearchResults(
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,
@@ -1115,7 +1115,6 @@ internal fun SettingsPanelContent(
}
}
}
}
/**
* 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),
)
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
// 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)
}
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>
</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.
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. -->
@@ -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<String>(), badgesFor(1))
assertEquals(emptyList<String>(), badgesFor(2))
assertEquals(listOf("5.1"), badgesFor(6))
assertEquals(listOf("7.1"), badgesFor(8))
}
@@ -55,7 +55,7 @@ class MetadataHeroOrderTest {
)
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
assertEquals(listOf("STEREO"), mediaBadges(item))
assertEquals(emptyList<String>(), mediaBadges(item))
}
@Test
+48 -8
View File
@@ -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'
(
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'
)
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).
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' })