0.3.04
This commit is contained in:
@@ -62,7 +62,7 @@ val projectNoticeText =
|
|||||||
rootProject.file("NOTICE").readText()
|
rootProject.file("NOTICE").readText()
|
||||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||||
|
|
||||||
val defaultVersionName = "0.3.03"
|
val defaultVersionName = "0.3.04"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ object ServiceLocator {
|
|||||||
lateinit var remoteConfig: RemoteConfigManager
|
lateinit var remoteConfig: RemoteConfigManager
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Safe for previews and view-only screenshot fixtures that do not initialise the app. */
|
||||||
|
internal fun activeFontFamilyName(): String =
|
||||||
|
if (::remoteConfig.isInitialized) remoteConfig.active.presentation.fontFamily else "system"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Held rather than discarded because it is a long-lived collector, not a service
|
* Held rather than discarded because it is a long-lived collector, not a service
|
||||||
* anything calls: it starts working when it is constructed and must not be collected
|
* anything calls: it starts working when it is constructed and must not be collected
|
||||||
|
|||||||
@@ -1208,8 +1208,8 @@ internal fun DetailFactRow(
|
|||||||
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
|
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
|
||||||
Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
Text(value, color = DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||||
}
|
}
|
||||||
// Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped
|
// Four, not three: picture, audio and airing metadata can all share this line, and
|
||||||
// the airing badge appended after them, which is the one that is news.
|
// the airing badge appended after them is the one that is news.
|
||||||
badges.take(4).forEach { badge ->
|
badges.take(4).forEach { badge ->
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
MediaBadge(badge)
|
MediaBadge(badge)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -386,7 +387,16 @@ private fun FeaturedMovieCard(
|
|||||||
contentDescription = "Featured, ${pick.label}, ${item.name}",
|
contentDescription = "Featured, ${pick.label}, ${item.name}",
|
||||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||||
) { focused ->
|
) { focused ->
|
||||||
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MembySurfaceRaised)
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
if (focused) Color.White else Color.Transparent,
|
||||||
|
RoundedCornerShape(MembyPanelCorner),
|
||||||
|
),
|
||||||
|
) {
|
||||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
@@ -539,7 +549,16 @@ private fun MiniMovieCard(
|
|||||||
contentDescription = "${pick.label} movie, ${item.name}",
|
contentDescription = "${pick.label} movie, ${item.name}",
|
||||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||||
) { focused ->
|
) { focused ->
|
||||||
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MembySurfaceRaised)
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
if (focused) Color.White else Color.Transparent,
|
||||||
|
RoundedCornerShape(MembyPanelCorner),
|
||||||
|
),
|
||||||
|
) {
|
||||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||||
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
|||||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.membyTypeface
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One answer in a television settings panel. The panel owns its invariant chrome while
|
* One answer in a television settings panel. The panel owns its invariant chrome while
|
||||||
@@ -170,7 +171,7 @@ fun tvSettingsOptionView(
|
|||||||
val title = TextView(context).apply {
|
val title = TextView(context).apply {
|
||||||
text = label
|
text = label
|
||||||
textSize = if (compact) 14f else 16f
|
textSize = if (compact) 14f else 16f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
}
|
}
|
||||||
copy.addView(title)
|
copy.addView(title)
|
||||||
@@ -178,7 +179,7 @@ fun tvSettingsOptionView(
|
|||||||
TextView(context).apply {
|
TextView(context).apply {
|
||||||
this.text = text
|
this.text = text
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
|
typeface = context.membyTypeface()
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
copy.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
copy.addView(this, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
topMargin = context.dp(2)
|
topMargin = context.dp(2)
|
||||||
@@ -190,7 +191,7 @@ fun tvSettingsOptionView(
|
|||||||
val trailing = TextView(context).apply {
|
val trailing = TextView(context).apply {
|
||||||
text = if (selected) "CURRENT" else value
|
text = if (selected) "CURRENT" else value
|
||||||
textSize = if (selected) 10f else 12f
|
textSize = if (selected) 10f else 12f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
gravity = Gravity.CENTER
|
gravity = Gravity.CENTER
|
||||||
letterSpacing = if (selected) 0.08f else 0f
|
letterSpacing = if (selected) 0.08f else 0f
|
||||||
visibility = if (text.isBlank()) View.GONE else View.VISIBLE
|
visibility = if (text.isBlank()) View.GONE else View.VISIBLE
|
||||||
@@ -271,14 +272,14 @@ private fun tvSettingsPanel(context: Context, title: String, description: String
|
|||||||
text = title
|
text = title
|
||||||
setTextColor(MembyOnSurface.toArgb())
|
setTextColor(MembyOnSurface.toArgb())
|
||||||
textSize = 22f
|
textSize = 22f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
})
|
})
|
||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
root.addView(TextView(context).apply {
|
root.addView(TextView(context).apply {
|
||||||
text = description
|
text = description
|
||||||
setTextColor(MembyMutedText.toArgb())
|
setTextColor(MembyMutedText.toArgb())
|
||||||
textSize = 14f
|
textSize = 14f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
|
typeface = context.membyTypeface()
|
||||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
topMargin = context.dp(5)
|
topMargin = context.dp(5)
|
||||||
})
|
})
|
||||||
@@ -334,7 +335,7 @@ private fun tvSettingsAction(context: Context, label: String, primary: Boolean,
|
|||||||
val button = TextView(context).apply {
|
val button = TextView(context).apply {
|
||||||
text = label
|
text = label
|
||||||
textSize = 14f
|
textSize = 14f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
gravity = Gravity.CENTER
|
gravity = Gravity.CENTER
|
||||||
isFocusable = true
|
isFocusable = true
|
||||||
isClickable = true
|
isClickable = true
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ import com.ponzischeme89.memby.data.model.BaseItem
|
|||||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
|
||||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||||
@@ -143,6 +142,23 @@ fun MediaBadge(
|
|||||||
fontSize: TextUnit = 11.sp,
|
fontSize: TextUnit = 11.sp,
|
||||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
) {
|
) {
|
||||||
|
val surroundSoundDescription = when (label) {
|
||||||
|
"5.1" -> "5.1 surround sound"
|
||||||
|
"7.1" -> "7.1 surround sound"
|
||||||
|
"DOLBY ATMOS" -> "Dolby Atmos surround sound"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (surroundSoundDescription != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.icon_surround_sound),
|
||||||
|
contentDescription = surroundSoundDescription,
|
||||||
|
modifier = modifier
|
||||||
|
.height(18.dp)
|
||||||
|
.aspectRatio(1.5f),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
color = MembyOnSurface,
|
color = MembyOnSurface,
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ import com.ponzischeme89.memby.data.model.BaseItem
|
|||||||
import com.ponzischeme89.memby.data.model.MembyViewer
|
import com.ponzischeme89.memby.data.model.MembyViewer
|
||||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
|
||||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||||
@@ -202,9 +201,11 @@ internal fun mediaBadges(item: BaseItem): List<String> {
|
|||||||
// HDR10+ in one place and plain HDR in the other.
|
// HDR10+ in one place and plain HDR in the other.
|
||||||
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
|
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
|
||||||
?.let { add(it.uppercase(Locale.US)) }
|
?.let { add(it.uppercase(Locale.US)) }
|
||||||
if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC")
|
when {
|
||||||
audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) }
|
audio?.title?.contains("atmos", true) == true -> add("DOLBY ATMOS")
|
||||||
if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS")
|
audio?.channels == 8 -> add("7.1")
|
||||||
|
audio?.channels == 6 -> add("5.1")
|
||||||
|
}
|
||||||
}.distinct()
|
}.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -237,13 +240,19 @@ private fun MetadataContent(
|
|||||||
) {
|
) {
|
||||||
metadataHeroContentOrder(contentOrder).forEach { section ->
|
metadataHeroContentOrder(contentOrder).forEach { section ->
|
||||||
when (section) {
|
when (section) {
|
||||||
MetadataHeroSection.Title -> MetadataHeroTitle(item, logoUrl, logo, compact)
|
MetadataHeroSection.Title -> MetadataHeroTitle(
|
||||||
|
item = item,
|
||||||
|
logoUrl = logoUrl,
|
||||||
|
logo = logo,
|
||||||
|
compact = compact,
|
||||||
|
showEpisodeLabel = !isContinueWatchingItem,
|
||||||
|
)
|
||||||
MetadataHeroSection.Ratings -> ItemRatingsStrip(
|
MetadataHeroSection.Ratings -> ItemRatingsStrip(
|
||||||
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
|
item = item, load = true, compact = true, modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
MetadataHeroSection.Facts -> {
|
MetadataHeroSection.Facts -> {
|
||||||
// Catalogue facts and the outlined file badges are one metadata line.
|
// Catalogue facts and the outlined file badges are one metadata line.
|
||||||
// Badges are measured first so Stereo/5.1 and picture formats remain
|
// Badges are measured first so the surround marker and picture formats remain
|
||||||
// visible; a long genre list gives way through ellipsis before the
|
// visible; a long genre list gives way through ellipsis before the
|
||||||
// file information wraps or escapes the hero.
|
// file information wraps or escapes the hero.
|
||||||
if (facts.isNotEmpty() || badges.isNotEmpty()) {
|
if (facts.isNotEmpty() || badges.isNotEmpty()) {
|
||||||
@@ -285,12 +294,48 @@ private fun MetadataContent(
|
|||||||
colour = timeRemainingColour,
|
colour = timeRemainingColour,
|
||||||
isContinueWatchingItem = isContinueWatchingItem,
|
isContinueWatchingItem = isContinueWatchingItem,
|
||||||
)
|
)
|
||||||
MetadataHeroSection.Summary -> Text(
|
MetadataHeroSection.Summary -> Column(
|
||||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
|
||||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
|
||||||
maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
) {
|
||||||
|
item.taglines
|
||||||
|
.firstOrNull { item.isMovie && it.isNotBlank() }
|
||||||
|
?.let { tagline ->
|
||||||
|
Text(
|
||||||
|
tagline,
|
||||||
|
color = EmbyGreen,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val overview = item.overview
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
?: "No description available."
|
||||||
|
val episodePrefix = if (isContinueWatchingItem && item.isEpisode) {
|
||||||
|
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
episodePrefix?.let { prefix ->
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(prefix)
|
||||||
|
}
|
||||||
|
append(" — ")
|
||||||
|
}
|
||||||
|
append(overview)
|
||||||
|
},
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
maxLines = if (compact) 2 else 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MetadataStatus(item)
|
MetadataStatus(item)
|
||||||
@@ -328,7 +373,13 @@ internal fun metadataHeroFacts(item: BaseItem): List<String> = buildList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, compact: Boolean) {
|
private fun MetadataHeroTitle(
|
||||||
|
item: BaseItem,
|
||||||
|
logoUrl: String?,
|
||||||
|
logo: String?,
|
||||||
|
compact: Boolean,
|
||||||
|
showEpisodeLabel: Boolean,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().then(
|
modifier = Modifier.fillMaxWidth().then(
|
||||||
if (logoUrl != null) Modifier.height(MetadataHeroLogoMaxHeight)
|
if (logoUrl != null) Modifier.height(MetadataHeroLogoMaxHeight)
|
||||||
@@ -349,7 +400,7 @@ private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, c
|
|||||||
fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (item.isEpisode) {
|
if (item.isEpisode && showEpisodeLabel) {
|
||||||
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
|
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ enum class BrowseDestination(val label: String, val icon: MembyIcon) {
|
|||||||
MOVIES("Movies", MembyIcon.Movie),
|
MOVIES("Movies", MembyIcon.Movie),
|
||||||
SHOWS("TV Shows", MembyIcon.Tv),
|
SHOWS("TV Shows", MembyIcon.Tv),
|
||||||
FOR_YOU("For You", MembyIcon.Sparkle),
|
FOR_YOU("For You", MembyIcon.Sparkle),
|
||||||
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
|
||||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
|
||||||
CALENDAR("TV Calendar", MembyIcon.Calendar),
|
CALENDAR("TV Calendar", MembyIcon.Calendar),
|
||||||
FAVORITES("Favourites", MembyIcon.Favourite),
|
FAVORITES("Favourites", MembyIcon.Favourite),
|
||||||
PROFILES("User", MembyIcon.Person),
|
PROFILES("User", MembyIcon.Person),
|
||||||
@@ -251,7 +249,7 @@ fun TvNavigationRail(
|
|||||||
model = markPath,
|
model = markPath,
|
||||||
contentDescription = "Memby",
|
contentDescription = "Memby",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(30.dp)
|
.size(34.dp)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = logoScale.value
|
scaleX = logoScale.value
|
||||||
scaleY = logoScale.value
|
scaleY = logoScale.value
|
||||||
@@ -261,7 +259,7 @@ fun TvNavigationRail(
|
|||||||
) else Image(
|
) else Image(
|
||||||
painter = painterResource(R.drawable.memby_mark),
|
painter = painterResource(R.drawable.memby_mark),
|
||||||
contentDescription = "Memby",
|
contentDescription = "Memby",
|
||||||
modifier = Modifier.size(30.dp).graphicsLayer {
|
modifier = Modifier.size(34.dp).graphicsLayer {
|
||||||
scaleX = logoScale.value; scaleY = logoScale.value; alpha = logoAlpha.value; rotationZ = logoRotation.value
|
scaleX = logoScale.value; scaleY = logoScale.value; alpha = logoAlpha.value; rotationZ = logoRotation.value
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
|
import com.ponzischeme89.memby.ui.theme.membyTypeface
|
||||||
|
|
||||||
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
|
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
|
||||||
data class CastMember(
|
data class CastMember(
|
||||||
@@ -141,10 +142,10 @@ private fun castCard(
|
|||||||
)
|
)
|
||||||
setPadding(0, dp(9), 0, 0)
|
setPadding(0, dp(9), 0, 0)
|
||||||
addView(TextView(context).apply {
|
addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
text = member.name
|
text = member.name
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
textSize = 14f
|
textSize = 14f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
ellipsize = TextUtils.TruncateAt.END
|
||||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||||
@@ -159,6 +160,7 @@ private fun castCard(
|
|||||||
member.role?.takeIf(String::isNotBlank)?.let { role ->
|
member.role?.takeIf(String::isNotBlank)?.let { role ->
|
||||||
addView(
|
addView(
|
||||||
TextView(context).apply {
|
TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface()
|
||||||
text = role
|
text = role
|
||||||
setTextColor(Color.rgb(158, 168, 178))
|
setTextColor(Color.rgb(158, 168, 178))
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
@@ -182,6 +184,7 @@ private fun bindPersonProfile(
|
|||||||
val life = personLifeDates(person.birthDate, person.deathDate)
|
val life = personLifeDates(person.birthDate, person.deathDate)
|
||||||
if (life.isNotBlank() || !person.role.isNullOrBlank()) {
|
if (life.isNotBlank() || !person.role.isNullOrBlank()) {
|
||||||
container.addView(TextView(context).apply {
|
container.addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface()
|
||||||
text = listOfNotNull(
|
text = listOfNotNull(
|
||||||
person.role?.takeIf(String::isNotBlank),
|
person.role?.takeIf(String::isNotBlank),
|
||||||
life.takeIf(String::isNotBlank),
|
life.takeIf(String::isNotBlank),
|
||||||
@@ -193,6 +196,7 @@ private fun bindPersonProfile(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
container.addView(TextView(context).apply {
|
container.addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface()
|
||||||
text = person.overview?.takeIf(String::isNotBlank)
|
text = person.overview?.takeIf(String::isNotBlank)
|
||||||
?: context.getString(R.string.player_cast_biography_empty)
|
?: context.getString(R.string.player_cast_biography_empty)
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
@@ -203,15 +207,16 @@ private fun bindPersonProfile(
|
|||||||
setPadding(0, dp(7), 0, 0)
|
setPadding(0, dp(7), 0, 0)
|
||||||
})
|
})
|
||||||
container.addView(TextView(context).apply {
|
container.addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
text = context.getString(R.string.player_cast_filmography)
|
text = context.getString(R.string.player_cast_filmography)
|
||||||
setTextColor(Color.rgb(82, 181, 75))
|
setTextColor(Color.rgb(82, 181, 75))
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
|
||||||
letterSpacing = 0.12f
|
letterSpacing = 0.12f
|
||||||
setPadding(0, dp(13), 0, dp(5))
|
setPadding(0, dp(13), 0, dp(5))
|
||||||
})
|
})
|
||||||
if (person.filmography.isEmpty()) {
|
if (person.filmography.isEmpty()) {
|
||||||
container.addView(TextView(context).apply {
|
container.addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface()
|
||||||
text = context.getString(R.string.player_cast_filmography_empty)
|
text = context.getString(R.string.player_cast_filmography_empty)
|
||||||
setTextColor(Color.rgb(185, 193, 200))
|
setTextColor(Color.rgb(185, 193, 200))
|
||||||
textSize = 13f
|
textSize = 13f
|
||||||
@@ -271,15 +276,16 @@ private fun filmographyCard(
|
|||||||
addView(LinearLayout(context).apply {
|
addView(LinearLayout(context).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
addView(TextView(context).apply {
|
addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
text = credit.title
|
text = credit.title
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
ellipsize = TextUtils.TruncateAt.END
|
||||||
})
|
})
|
||||||
credit.year?.let { year ->
|
credit.year?.let { year ->
|
||||||
addView(TextView(context).apply {
|
addView(TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface()
|
||||||
text = year.toString()
|
text = year.toString()
|
||||||
setTextColor(Color.rgb(158, 168, 178))
|
setTextColor(Color.rgb(158, 168, 178))
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
@@ -333,6 +339,7 @@ private fun castPortrait(
|
|||||||
|
|
||||||
addView(
|
addView(
|
||||||
TextView(context).apply {
|
TextView(context).apply {
|
||||||
|
typeface = context.membyTypeface()
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@@ -340,7 +347,6 @@ private fun castPortrait(
|
|||||||
text = castInitials(member.name)
|
text = castInitials(member.name)
|
||||||
setTextColor(Color.rgb(126, 138, 148))
|
setTextColor(Color.rgb(126, 138, 148))
|
||||||
textSize = 34f
|
textSize = 34f
|
||||||
typeface = Typeface.create("sans-serif-light", Typeface.NORMAL)
|
|
||||||
gravity = Gravity.CENTER
|
gravity = Gravity.CENTER
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ import com.ponzischeme89.memby.ui.components.TvSettingsMenuOption
|
|||||||
import com.ponzischeme89.memby.ui.components.showTvSettingsMenu
|
import com.ponzischeme89.memby.ui.components.showTvSettingsMenu
|
||||||
import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu
|
import com.ponzischeme89.memby.ui.components.showTvSettingsMultiChoiceMenu
|
||||||
import com.ponzischeme89.memby.ui.randomWelcomeQuote
|
import com.ponzischeme89.memby.ui.randomWelcomeQuote
|
||||||
|
import com.ponzischeme89.memby.ui.theme.AppFontFamily
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||||
|
import com.ponzischeme89.memby.ui.theme.applyMembyTypeface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.appFontFamily
|
||||||
|
import com.ponzischeme89.memby.ui.theme.membyTypeface
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -539,6 +543,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
if (appFontFamily(ServiceLocator.remoteConfig.active.presentation.fontFamily) == AppFontFamily.INTER) {
|
||||||
|
setTheme(R.style.Theme_Memby_Inter_Fullscreen)
|
||||||
|
}
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
@@ -637,6 +644,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
||||||
|
|
||||||
setContentView(R.layout.activity_player)
|
setContentView(R.layout.activity_player)
|
||||||
|
findViewById<View>(android.R.id.content).applyMembyTypeface()
|
||||||
val enhancementsAvailable = membyPlayerEnhancementsAvailable(
|
val enhancementsAvailable = membyPlayerEnhancementsAvailable(
|
||||||
playingTrailer = playingTrailer,
|
playingTrailer = playingTrailer,
|
||||||
)
|
)
|
||||||
@@ -1764,6 +1772,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
|
private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
|
||||||
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
|
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
|
||||||
|
applyMembyTypeface()
|
||||||
findViewById<TextView>(R.id.player_preroll_card_label).text = label
|
findViewById<TextView>(R.id.player_preroll_card_label).text = label
|
||||||
findViewById<TextView>(R.id.player_preroll_card_title).text = entry.series
|
findViewById<TextView>(R.id.player_preroll_card_title).text = entry.series
|
||||||
findViewById<TextView>(R.id.player_preroll_card_detail).text = listOfNotNull(
|
findViewById<TextView>(R.id.player_preroll_card_detail).text = listOfNotNull(
|
||||||
@@ -1784,6 +1793,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun prerollMessage(message: String): TextView =
|
private fun prerollMessage(message: String): TextView =
|
||||||
TextView(this).apply {
|
TextView(this).apply {
|
||||||
|
typeface = membyTypeface()
|
||||||
text = message
|
text = message
|
||||||
setTextColor(Color.rgb(142, 151, 157))
|
setTextColor(Color.rgb(142, 151, 157))
|
||||||
textSize = 14f
|
textSize = 14f
|
||||||
@@ -4656,7 +4666,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// the player is not a Compose screen — dispose with the view, not the window.
|
// the player is not a Compose screen — dispose with the view, not the window.
|
||||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||||
setContent {
|
setContent {
|
||||||
MembyTheme {
|
MembyTheme(fontFamilyName = ServiceLocator.remoteConfig.active.presentation.fontFamily) {
|
||||||
// This is where the outage bar earns its place: the stream comes
|
// This is where the outage bar earns its place: the stream comes
|
||||||
// straight from Emby, so when Emby stops answering the film stalls
|
// straight from Emby, so when Emby stops answering the film stalls
|
||||||
// with nothing on screen to explain it. The news bar yields the strip
|
// with nothing on screen to explain it. The news bar yields the strip
|
||||||
@@ -4705,7 +4715,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
Color.TRANSPARENT,
|
Color.TRANSPARENT,
|
||||||
CaptionStyleCompat.EDGE_TYPE_OUTLINE,
|
CaptionStyleCompat.EDGE_TYPE_OUTLINE,
|
||||||
Color.BLACK,
|
Color.BLACK,
|
||||||
null,
|
membyTypeface(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
setFractionalTextSize(size.fraction)
|
setFractionalTextSize(size.fraction)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.graphics.RectF
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import com.ponzischeme89.memby.ui.theme.membyTypeface
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +41,7 @@ class PrerollCountdownView @JvmOverloads constructor(
|
|||||||
30f,
|
30f,
|
||||||
resources.displayMetrics,
|
resources.displayMetrics,
|
||||||
)
|
)
|
||||||
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
|
typeface = context.membyTypeface(android.graphics.Typeface.BOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var seconds = 7
|
private var seconds = 7
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.graphics.Typeface
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
|
import com.ponzischeme89.memby.ui.theme.membyTypeface
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ class SkipIntroCountdownView @JvmOverloads constructor(
|
|||||||
private val progressPaint = Paint(trackPaint)
|
private val progressPaint = Paint(trackPaint)
|
||||||
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
textAlign = Paint.Align.CENTER
|
textAlign = Paint.Align.CENTER
|
||||||
typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
typeface = context.membyTypeface(Typeface.BOLD)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var figure = ""
|
private var figure = ""
|
||||||
|
|||||||
@@ -154,9 +154,12 @@ fun SearchScreen(
|
|||||||
|
|
||||||
val genresEntry = remember { FocusRequester() }
|
val genresEntry = remember { FocusRequester() }
|
||||||
val searchResultsEntry = remember { FocusRequester() }
|
val searchResultsEntry = remember { FocusRequester() }
|
||||||
// The rail and the screen agree on one entry target: Search opens on the keyboard,
|
val keyboardEntry = remember { FocusRequester() }
|
||||||
// just as browse destinations open on their primary content action.
|
val context = LocalContext.current
|
||||||
val keyboardEntry = contentFocusRequester
|
val voiceAvailable = remember { voiceSearchAvailable(context) }
|
||||||
|
// Search opens on its voice action when this television has one. The shared content
|
||||||
|
// requester is attached to that button below; without recognition support it remains
|
||||||
|
// attached to the first letter, preserving the keyboard-only fallback.
|
||||||
// Where "left out of the results" lands. It follows the last key used, so coming back
|
// Where "left out of the results" lands. It follows the last key used, so coming back
|
||||||
// returns the viewer to where they were typing rather than to a fixed corner.
|
// returns the viewer to where they were typing rather than to a fixed corner.
|
||||||
val keyboardReturn = remember { FocusRequester() }
|
val keyboardReturn = remember { FocusRequester() }
|
||||||
@@ -181,7 +184,7 @@ fun SearchScreen(
|
|||||||
(state.requestsAvailable && shouldSearch(state.query))
|
(state.requestsAvailable && shouldSearch(state.query))
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
LaunchedEffect(voiceAvailable) { runCatching { contentFocusRequester.requestFocus() } }
|
||||||
|
|
||||||
// The genre strip remains above its shelf. Once a first result exists the list takes
|
// The genre strip remains above its shelf. Once a first result exists the list takes
|
||||||
// focus, which is where the viewer is already looking; an empty genre hands the remote
|
// focus, which is where the viewer is already looking; an empty genre hands the remote
|
||||||
@@ -263,6 +266,8 @@ fun SearchScreen(
|
|||||||
onBackspace = viewModel::backspace,
|
onBackspace = viewModel::backspace,
|
||||||
onClear = viewModel::clearQuery,
|
onClear = viewModel::clearQuery,
|
||||||
onVoiceResult = viewModel::onQueryChanged,
|
onVoiceResult = viewModel::onQueryChanged,
|
||||||
|
voiceAvailable = voiceAvailable,
|
||||||
|
voiceFocusRequester = contentFocusRequester,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
|
.fillMaxWidth(KEYBOARD_PANE_FRACTION)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -318,6 +323,7 @@ fun SearchScreen(
|
|||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onShowRequests = viewModel::showRequests,
|
onShowRequests = viewModel::showRequests,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
|
voiceAvailable = voiceAvailable,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -339,6 +345,8 @@ private fun SearchPane(
|
|||||||
onBackspace: () -> Unit,
|
onBackspace: () -> Unit,
|
||||||
onClear: () -> Unit,
|
onClear: () -> Unit,
|
||||||
onVoiceResult: (String) -> Unit,
|
onVoiceResult: (String) -> Unit,
|
||||||
|
voiceAvailable: Boolean,
|
||||||
|
voiceFocusRequester: FocusRequester,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -352,12 +360,21 @@ private fun SearchPane(
|
|||||||
query = state.query,
|
query = state.query,
|
||||||
loading = state.isLoading,
|
loading = state.isLoading,
|
||||||
onVoiceResult = onVoiceResult,
|
onVoiceResult = onVoiceResult,
|
||||||
|
onVoiceFocused = {
|
||||||
|
onKeyFocused(lastKeyIndex)
|
||||||
|
},
|
||||||
|
micModifier = Modifier
|
||||||
|
.focusRequester(voiceFocusRequester)
|
||||||
|
.focusProperties {
|
||||||
|
left = navigationFocusRequester
|
||||||
|
down = keyboardEntry
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
TvKeyboard(
|
TvKeyboard(
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
resultsEntry = resultsEntry,
|
resultsEntry = resultsEntry,
|
||||||
keyboardEntry = keyboardEntry,
|
keyboardEntry = if (voiceAvailable) keyboardEntry else voiceFocusRequester,
|
||||||
keyboardReturn = keyboardReturn,
|
keyboardReturn = keyboardReturn,
|
||||||
lastKeyIndex = lastKeyIndex,
|
lastKeyIndex = lastKeyIndex,
|
||||||
hasResultsTarget = hasResultsTarget,
|
hasResultsTarget = hasResultsTarget,
|
||||||
@@ -365,6 +382,7 @@ private fun SearchPane(
|
|||||||
onCharacter = onCharacter,
|
onCharacter = onCharacter,
|
||||||
onBackspace = onBackspace,
|
onBackspace = onBackspace,
|
||||||
onClear = onClear,
|
onClear = onClear,
|
||||||
|
upTarget = if (voiceAvailable) voiceFocusRequester else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,6 +421,7 @@ internal fun SearchQueryField(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
placeholder: String = "Search movies, shows and episodes",
|
placeholder: String = "Search movies, shows and episodes",
|
||||||
micModifier: Modifier = Modifier,
|
micModifier: Modifier = Modifier,
|
||||||
|
onVoiceFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val voiceAvailable = remember { voiceSearchAvailable(context) }
|
val voiceAvailable = remember { voiceSearchAvailable(context) }
|
||||||
@@ -445,41 +464,12 @@ internal fun SearchQueryField(
|
|||||||
) { startVoiceSearch() }
|
) { startVoiceSearch() }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(MembySurfaceRaised)
|
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
|
||||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
MembyIcon.Search.mark,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (query.isEmpty()) Muted else Accent,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Text(
|
|
||||||
text = query.ifEmpty { placeholder },
|
|
||||||
color = if (query.isEmpty()) Muted else Heading,
|
|
||||||
// Fixed size rather than shrinking as the query grows: readable at three
|
|
||||||
// metres matters more than fitting a long query on one line.
|
|
||||||
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
|
|
||||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (loading) {
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
LoadingDot()
|
|
||||||
}
|
|
||||||
if (voiceAvailable) {
|
if (voiceAvailable) {
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
FocusScaleContainer(
|
FocusScaleContainer(
|
||||||
onFocused = {},
|
onFocused = onVoiceFocused,
|
||||||
onClick = {
|
onClick = {
|
||||||
// Asked at the point of use rather than during setup: the microphone is
|
// Asked at the point of use rather than during setup: the microphone is
|
||||||
// only ever wanted by this one button, and a permission dialog in front
|
// only ever wanted by this one button, and a permission dialog in front
|
||||||
@@ -503,10 +493,44 @@ internal fun SearchQueryField(
|
|||||||
tint = if (focused) KeyLabelFocused else KeyLabel,
|
tint = if (focused) KeyLabelFocused else KeyLabel,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (focused) KeyFocused else KeyIdle)
|
.background(if (focused) KeyFocused else KeyIdle)
|
||||||
.padding(6.dp)
|
.padding(11.dp)
|
||||||
.size(18.dp),
|
.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(MembySurfaceRaised)
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
MembyIcon.Search.mark,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (query.isEmpty()) Muted else Accent,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = query.ifEmpty { placeholder },
|
||||||
|
color = if (query.isEmpty()) Muted else Heading,
|
||||||
|
// Fixed size rather than shrinking as the query grows: readable at three
|
||||||
|
// metres matters more than fitting a long query on one line.
|
||||||
|
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
|
||||||
|
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (loading) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
LoadingDot()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
package com.ponzischeme89.memby.ui.search.components
|
package com.ponzischeme89.memby.ui.search.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -8,10 +15,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,10 +32,12 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.ui.MembyChoiceChip
|
import com.ponzischeme89.memby.ui.MembyChoiceChip
|
||||||
@@ -37,8 +48,10 @@ import com.ponzischeme89.memby.ui.search.SearchUiState
|
|||||||
import com.ponzischeme89.memby.ui.search.shouldSearch
|
import com.ponzischeme89.memby.ui.search.shouldSearch
|
||||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.mark
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
/** Search-owned result state rendered through the same media row as My Requests. */
|
/** Search-owned result state rendered through the same media row as My Requests. */
|
||||||
@@ -57,6 +70,7 @@ internal fun SearchResults(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onShowRequests: () -> Unit,
|
onShowRequests: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
|
voiceAvailable: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val items = state.results.filter { it.isMovie || it.isSeries }
|
val items = state.results.filter { it.isMovie || it.isSeries }
|
||||||
@@ -73,15 +87,18 @@ internal fun SearchResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val showStartPrompt = state.isDiscovery && items.isEmpty()
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
SearchResultsHeading(
|
if (!showStartPrompt) {
|
||||||
state = state,
|
SearchResultsHeading(
|
||||||
resultCount = items.size,
|
state = state,
|
||||||
onShowRequests = onShowRequests,
|
resultCount = items.size,
|
||||||
requestFocusRequester = entryFocusRequester,
|
onShowRequests = onShowRequests,
|
||||||
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
requestFocusRequester = entryFocusRequester,
|
||||||
)
|
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
||||||
Spacer(Modifier.height(8.dp))
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
state.isLoading && items.isEmpty() -> Column(
|
state.isLoading && items.isEmpty() -> Column(
|
||||||
@@ -100,6 +117,8 @@ internal fun SearchResults(
|
|||||||
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
showStartPrompt -> SearchStartPrompt(voiceAvailable)
|
||||||
|
|
||||||
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
|
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
|
||||||
|
|
||||||
else -> LazyColumn(
|
else -> LazyColumn(
|
||||||
@@ -163,6 +182,54 @@ internal fun SearchResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchStartPrompt(voiceAvailable: Boolean) {
|
||||||
|
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
|
||||||
|
initialValue = 0.96f,
|
||||||
|
targetValue = 1.04f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(1_200), RepeatMode.Reverse),
|
||||||
|
label = "search-start-prompt-scale",
|
||||||
|
)
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(54.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = pulse.value
|
||||||
|
scaleY = pulse.value
|
||||||
|
}
|
||||||
|
.background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (voiceAvailable) MembyIcon.Mic.mark else MembyIcon.Search.mark,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MembyAccent,
|
||||||
|
modifier = Modifier.size(25.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = if (voiceAvailable) {
|
||||||
|
"Use your voice or type a couple of letters"
|
||||||
|
} else {
|
||||||
|
"Type a couple of letters to begin"
|
||||||
|
},
|
||||||
|
color = MembyOnSurface,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"Movies, TV series and episodes are all searchable.",
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultsHeading(
|
private fun SearchResultsHeading(
|
||||||
state: SearchUiState,
|
state: SearchUiState,
|
||||||
|
|||||||
@@ -1115,7 +1115,6 @@ internal fun SettingsPanelContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby
|
* What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.theme
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import com.ponzischeme89.memby.R
|
||||||
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
|
|
||||||
|
/** The server-selected family for Android views and custom-drawn player copy. */
|
||||||
|
internal fun Context.membyTypeface(style: Int = Typeface.NORMAL): Typeface {
|
||||||
|
if (appFontFamily(ServiceLocator.activeFontFamilyName()) != AppFontFamily.INTER) {
|
||||||
|
return Typeface.create("sans-serif", style)
|
||||||
|
}
|
||||||
|
val resource = if (style and Typeface.BOLD != 0) R.font.inter_bold else R.font.inter_regular
|
||||||
|
return ResourcesCompat.getFont(this, resource) ?: Typeface.create("sans-serif", style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the active family to an already-inflated Android view hierarchy. */
|
||||||
|
internal fun View.applyMembyTypeface() {
|
||||||
|
if (this is TextView) typeface = context.membyTypeface(typeface?.style ?: Typeface.NORMAL)
|
||||||
|
if (this is ViewGroup) {
|
||||||
|
for (index in 0 until childCount) getChildAt(index).applyMembyTypeface()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ private val InterFontFamily = FontFamily(
|
|||||||
Font(R.font.inter_bold, FontWeight.Bold),
|
Font(R.font.inter_bold, FontWeight.Bold),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LocalTrialFontFamily = compositionLocalOf { FontFamily.SansSerif }
|
private val LocalAppFontFamily = compositionLocalOf<FontFamily> { FontFamily.SansSerif }
|
||||||
|
|
||||||
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
|
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
|
||||||
// a component that falls back to a theme colour lands on the surface it is sitting on
|
// a component that falls back to a theme colour lands on the surface it is sitting on
|
||||||
@@ -51,38 +51,38 @@ fun MembyTheme(
|
|||||||
fontFamilyName: String = "system",
|
fontFamilyName: String = "system",
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Use Android's complete Roboto-backed sans family rather than pinning every label
|
// The family is selected once at the root so every Compose surface agrees. System
|
||||||
// to sans-serif-medium. The generic family lets Compose select real regular,
|
// keeps Android's complete Roboto-backed sans family; Inter uses bundled faces for
|
||||||
// medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy
|
// each supported weight and therefore never flashes or waits on a download.
|
||||||
// and making body copy substantially easier to scan from TV distance.
|
val selectedFontFamily = when (appFontFamily(fontFamilyName)) {
|
||||||
//
|
AppFontFamily.SYSTEM -> FontFamily.SansSerif
|
||||||
// A platform family also has every script Android supports, adds no APK weight, and
|
AppFontFamily.INTER -> InterFontFamily
|
||||||
// cannot flash or fail while a downloadable font is fetched.
|
}
|
||||||
val appTextStyle = LocalTextStyle.current.copy(
|
val appTextStyle = LocalTextStyle.current.copy(
|
||||||
fontFamily = FontFamily.SansSerif,
|
fontFamily = selectedFontFamily,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
// Slightly open tracking and leading keep small metadata and multi-line
|
// Slightly open tracking and leading keep small metadata and multi-line
|
||||||
// summaries from feeling cramped without making large headings look loose.
|
// summaries from feeling cramped without making large headings look loose.
|
||||||
letterSpacing = 0.006.em,
|
letterSpacing = 0.006.em,
|
||||||
lineHeight = 1.22.em,
|
lineHeight = 1.22.em,
|
||||||
)
|
)
|
||||||
val trialFontFamily = when (appFontFamily(fontFamilyName)) {
|
|
||||||
AppFontFamily.SYSTEM -> FontFamily.SansSerif
|
|
||||||
AppFontFamily.INTER -> InterFontFamily
|
|
||||||
}
|
|
||||||
MaterialTheme(colorScheme = embyColors()) {
|
MaterialTheme(colorScheme = embyColors()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalTextStyle provides appTextStyle,
|
LocalTextStyle provides appTextStyle,
|
||||||
LocalTrialFontFamily provides trialFontFamily,
|
LocalAppFontFamily provides selectedFontFamily,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies the server-selected family only to the surfaces included in the visual trial. */
|
/**
|
||||||
|
* Compatibility boundary for the surfaces that originally took part in the font trial.
|
||||||
|
* The selected family now belongs to the root theme, so this preserves those call sites
|
||||||
|
* without limiting Inter to them.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MembyTrialTypography(content: @Composable () -> Unit) {
|
fun MembyTrialTypography(content: @Composable () -> Unit) {
|
||||||
val textStyle = LocalTextStyle.current.copy(fontFamily = LocalTrialFontFamily.current)
|
val textStyle = LocalTextStyle.current.copy(fontFamily = LocalAppFontFamily.current)
|
||||||
CompositionLocalProvider(LocalTextStyle provides textStyle, content = content)
|
CompositionLocalProvider(LocalTextStyle provides textStyle, content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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>
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Selected before PlayerActivity inflates anything, covering its XML layouts,
|
||||||
|
Media3 controller, dialogs, dynamically-created rows and custom text paints. -->
|
||||||
|
<style name="Theme.Memby.Inter.Fullscreen" parent="Theme.Memby.Fullscreen">
|
||||||
|
<item name="android:fontFamily">@font/inter_family</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- MainActivity alone gets the local poster wall before its first Compose frame.
|
<!-- MainActivity alone gets the local poster wall before its first Compose frame.
|
||||||
Player and screensaver windows keep their black background, where a launch poster
|
Player and screensaver windows keep their black background, where a launch poster
|
||||||
appearing behind a video surface would be a flash rather than a welcome. -->
|
appearing behind a video surface would be a flash rather than a welcome. -->
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.junit.Test
|
|||||||
|
|
||||||
class MediaBadgesTest {
|
class MediaBadgesTest {
|
||||||
@Test
|
@Test
|
||||||
fun `derives premium video and audio badges without duplicates`() {
|
fun `derives viewer-facing picture and audio badges without codec labels`() {
|
||||||
val item = BaseItem(
|
val item = BaseItem(
|
||||||
id = "movie",
|
id = "movie",
|
||||||
mediaStreams = listOf(
|
mediaStreams = listOf(
|
||||||
@@ -23,13 +23,13 @@ class MediaBadgesTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"),
|
listOf("4K", "DOLBY VISION", "DOLBY ATMOS"),
|
||||||
mediaBadges(item),
|
mediaBadges(item),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `includes surround and non-surround sound profiles`() {
|
fun `includes supported surround profiles and omits other channel counts`() {
|
||||||
fun badgesFor(channels: Int) = mediaBadges(
|
fun badgesFor(channels: Int) = mediaBadges(
|
||||||
BaseItem(
|
BaseItem(
|
||||||
id = "movie-$channels",
|
id = "movie-$channels",
|
||||||
@@ -37,8 +37,8 @@ class MediaBadgesTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("MONO"), badgesFor(1))
|
assertEquals(emptyList<String>(), badgesFor(1))
|
||||||
assertEquals(listOf("STEREO"), badgesFor(2))
|
assertEquals(emptyList<String>(), badgesFor(2))
|
||||||
assertEquals(listOf("5.1"), badgesFor(6))
|
assertEquals(listOf("5.1"), badgesFor(6))
|
||||||
assertEquals(listOf("7.1"), badgesFor(8))
|
assertEquals(listOf("7.1"), badgesFor(8))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class MetadataHeroOrderTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
|
assertEquals(listOf("2026", "2h 4m", "M", "Drama · Adventure"), metadataHeroFacts(item))
|
||||||
assertEquals(listOf("STEREO"), mediaBadges(item))
|
assertEquals(emptyList<String>(), mediaBadges(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+51
-11
@@ -28,6 +28,11 @@ by this script.
|
|||||||
This deploys the current local working tree, including uncommitted server changes.
|
This deploys the current local working tree, including uncommitted server changes.
|
||||||
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
|
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
|
||||||
|
|
||||||
|
Use -Fast for a quicker full deployment. It skips Android unit and screenshot tests and
|
||||||
|
reuses cached Docker dependency/base images when available. The signed APK build and
|
||||||
|
verification, container builds, activation, rollback, release publishing and health checks
|
||||||
|
still run.
|
||||||
|
|
||||||
Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The
|
Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The
|
||||||
console is its own container with its own health check and nothing depends on it, so
|
console is its own container with its own health check and nothing depends on it, so
|
||||||
only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one
|
only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one
|
||||||
@@ -57,6 +62,9 @@ deployment and refuses to create one.
|
|||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\deploy-server.ps1 -SkipAppRelease
|
.\deploy-server.ps1 -SkipAppRelease
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy-server.ps1 -Fast
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\deploy-server.ps1 --Admin
|
.\deploy-server.ps1 --Admin
|
||||||
|
|
||||||
@@ -98,6 +106,9 @@ param(
|
|||||||
[Parameter()]
|
[Parameter()]
|
||||||
[switch] $SkipAppTests,
|
[switch] $SkipAppTests,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[switch] $Fast,
|
||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[switch] $SkipAppRelease,
|
[switch] $SkipAppRelease,
|
||||||
|
|
||||||
@@ -137,7 +148,7 @@ Set-StrictMode -Version Latest
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||||||
$unknownFlags = @($trailingFlags | Where-Object {
|
$unknownFlags = @($trailingFlags | Where-Object {
|
||||||
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin')
|
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin', '--Fast', '--fast')
|
||||||
})
|
})
|
||||||
if ($unknownFlags.Count -gt 0) {
|
if ($unknownFlags.Count -gt 0) {
|
||||||
throw "Unknown deployment option: $($unknownFlags -join ', ')"
|
throw "Unknown deployment option: $($unknownFlags -join ', ')"
|
||||||
@@ -145,6 +156,15 @@ if ($unknownFlags.Count -gt 0) {
|
|||||||
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
|
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
|
||||||
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
|
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
|
||||||
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
|
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
|
||||||
|
$fastDeployment = $Fast -or $trailingFlags -contains '--Fast' -or $trailingFlags -contains '--fast'
|
||||||
|
|
||||||
|
if ($fastDeployment) {
|
||||||
|
# Screenshot tests are ordinary testDebugUnitTest classes in this repository, so the
|
||||||
|
# only reliable way to keep them out of a deployment build is to omit that Gradle task.
|
||||||
|
# assembleRelease still compiles the production app and the checks below still prove
|
||||||
|
# that the APK is signed and carries the requested version.
|
||||||
|
$SkipAppTests = $true
|
||||||
|
}
|
||||||
|
|
||||||
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
|
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
|
||||||
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
|
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
|
||||||
@@ -164,7 +184,7 @@ if ($consoleOnly) {
|
|||||||
# measured twice: a console run rebuilds one small image and a full one swaps the whole
|
# measured twice: a console run rebuilds one small image and a full one swaps the whole
|
||||||
# stack. Averaged together, each estimate would be wrong for both. A record written before
|
# stack. Averaged together, each estimate would be wrong for both. A record written before
|
||||||
# this existed carries no kind and is read as 'full', which is what all of them were.
|
# this existed carries no kind and is read as 'full', which is what all of them were.
|
||||||
$script:DeploymentKind = if ($consoleOnly) { 'console' } else { 'full' }
|
$script:DeploymentKind = if ($consoleOnly) { 'console' } elseif ($fastDeployment) { 'fast' } else { 'full' }
|
||||||
|
|
||||||
$script:CurrentStep = 0
|
$script:CurrentStep = 0
|
||||||
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
|
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
|
||||||
@@ -184,11 +204,11 @@ $script:FallbackSeconds = @{
|
|||||||
'prerequisites' = 3
|
'prerequisites' = 3
|
||||||
'source' = 2
|
'source' = 2
|
||||||
'payload' = 4
|
'payload' = 4
|
||||||
'android-release' = 230
|
'android-release' = if ($fastDeployment) { 150 } else { 230 }
|
||||||
'packaging' = 12
|
'packaging' = 12
|
||||||
# A console deployment uploads one small build context and rebuilds one image, where a
|
# A console deployment uploads one small build context and rebuilds one image, where a
|
||||||
# full one ships the Go tree as well and rebuilds the whole stack.
|
# full one ships the Go tree as well and rebuilds the whole stack.
|
||||||
'remote-deployment' = if ($consoleOnly) { 90 } else { 330 }
|
'remote-deployment' = if ($consoleOnly) { 90 } elseif ($fastDeployment) { 270 } else { 330 }
|
||||||
}
|
}
|
||||||
$script:PhaseDurations = [ordered]@{}
|
$script:PhaseDurations = [ordered]@{}
|
||||||
$script:CurrentPhaseKey = $null
|
$script:CurrentPhaseKey = $null
|
||||||
@@ -435,7 +455,7 @@ function Show-DeploymentEstimate {
|
|||||||
'prerequisites' = 'Local prerequisites'
|
'prerequisites' = 'Local prerequisites'
|
||||||
'source' = 'Source selection'
|
'source' = 'Source selection'
|
||||||
'payload' = 'Compose validation'
|
'payload' = 'Compose validation'
|
||||||
'android-release' = 'Android build and tests'
|
'android-release' = if ($fastDeployment) { 'Android release build' } else { 'Android build and tests' }
|
||||||
'packaging' = 'Release packaging'
|
'packaging' = 'Release packaging'
|
||||||
'remote-deployment' = 'Upload, remote build and activation'
|
'remote-deployment' = 'Upload, remote build and activation'
|
||||||
}
|
}
|
||||||
@@ -471,6 +491,9 @@ function Write-Banner {
|
|||||||
if ($quietDeployment) {
|
if ($quietDeployment) {
|
||||||
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
||||||
}
|
}
|
||||||
|
if ($fastDeployment) {
|
||||||
|
Write-Styled -Message '│ MODE fast (tests and forced image refresh skipped)' -Colour Yellow
|
||||||
|
}
|
||||||
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
|
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
|
||||||
Measure-Object -Sum).Sum
|
Measure-Object -Sum).Sum
|
||||||
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
|
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
|
||||||
@@ -901,6 +924,10 @@ try {
|
|||||||
$gradleArguments = @('--console=plain')
|
$gradleArguments = @('--console=plain')
|
||||||
if (-not $SkipAppTests) {
|
if (-not $SkipAppTests) {
|
||||||
$gradleArguments += 'testDebugUnitTest'
|
$gradleArguments += 'testDebugUnitTest'
|
||||||
|
} elseif ($fastDeployment) {
|
||||||
|
Write-Detail 'Fast mode: skipping Android unit and screenshot tests'
|
||||||
|
} else {
|
||||||
|
Write-Detail 'Android tests skipped by -SkipAppTests'
|
||||||
}
|
}
|
||||||
$gradleArguments += @(
|
$gradleArguments += @(
|
||||||
'assembleRelease',
|
'assembleRelease',
|
||||||
@@ -1188,6 +1215,7 @@ health_timeout=__HEALTH_TIMEOUT__
|
|||||||
publish_release=__PUBLISH_RELEASE__
|
publish_release=__PUBLISH_RELEASE__
|
||||||
mandatory_update=__MANDATORY_UPDATE__
|
mandatory_update=__MANDATORY_UPDATE__
|
||||||
quiet_deployment=__QUIET_DEPLOYMENT__
|
quiet_deployment=__QUIET_DEPLOYMENT__
|
||||||
|
fast_deployment=__FAST_DEPLOYMENT__
|
||||||
colour_output=__COLOUR_OUTPUT__
|
colour_output=__COLOUR_OUTPUT__
|
||||||
remote_step_offset=__REMOTE_STEP_OFFSET__
|
remote_step_offset=__REMOTE_STEP_OFFSET__
|
||||||
total_steps=__TOTAL_STEPS__
|
total_steps=__TOTAL_STEPS__
|
||||||
@@ -1423,11 +1451,15 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
step 'Pulling PostgreSQL and Redis'
|
step 'Pulling PostgreSQL and Redis'
|
||||||
(
|
if [ "$fast_deployment" -eq 1 ]; then
|
||||||
cd "$staging"
|
detail 'Fast mode; reusing cached dependency images when available'
|
||||||
docker compose pull postgres redis
|
else
|
||||||
)
|
(
|
||||||
success 'Dependency images are ready'
|
cd "$staging"
|
||||||
|
docker compose pull postgres redis
|
||||||
|
)
|
||||||
|
success 'Dependency images are ready'
|
||||||
|
fi
|
||||||
|
|
||||||
step 'Building the gateway and admin console'
|
step 'Building the gateway and admin console'
|
||||||
(
|
(
|
||||||
@@ -1435,7 +1467,11 @@ step 'Building the gateway and admin console'
|
|||||||
# `up` reuses an existing image when one is present. Build both local contexts here,
|
# `up` reuses an existing image when one is present. Build both local contexts here,
|
||||||
# otherwise a new React/nginx console can be packaged and activated while the NAS
|
# otherwise a new React/nginx console can be packaged and activated while the NAS
|
||||||
# continues to serve the previous console image (and its old route configuration).
|
# continues to serve the previous console image (and its old route configuration).
|
||||||
docker compose build --pull server memby-admin
|
if [ "$fast_deployment" -eq 1 ]; then
|
||||||
|
docker compose build server memby-admin
|
||||||
|
else
|
||||||
|
docker compose build --pull server memby-admin
|
||||||
|
fi
|
||||||
)
|
)
|
||||||
success 'Gateway and admin console images built'
|
success 'Gateway and admin console images built'
|
||||||
|
|
||||||
@@ -1568,6 +1604,10 @@ success 'Memby gateway: https://mserver.sublogue.com'
|
|||||||
'__QUIET_DEPLOYMENT__',
|
'__QUIET_DEPLOYMENT__',
|
||||||
$(if ($quietDeployment) { '1' } else { '0' })
|
$(if ($quietDeployment) { '1' } else { '0' })
|
||||||
)
|
)
|
||||||
|
$remoteCommand = $remoteCommand.Replace(
|
||||||
|
'__FAST_DEPLOYMENT__',
|
||||||
|
$(if ($fastDeployment) { '1' } else { '0' })
|
||||||
|
)
|
||||||
$remoteCommand = $remoteCommand.Replace(
|
$remoteCommand = $remoteCommand.Replace(
|
||||||
'__COLOUR_OUTPUT__',
|
'__COLOUR_OUTPUT__',
|
||||||
$(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })
|
$(if ($script:UseColour -and -not [Console]::IsOutputRedirected) { '1' } else { '0' })
|
||||||
|
|||||||
Reference in New Issue
Block a user