0.3.04
This commit is contained in:
@@ -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,12 +294,48 @@ private fun MetadataContent(
|
||||
colour = timeRemainingColour,
|
||||
isContinueWatchingItem = isContinueWatchingItem,
|
||||
)
|
||||
MetadataHeroSection.Summary -> Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
||||
maxLines = if (compact) 2 else 3, overflow = TextOverflow.Ellipsis,
|
||||
MetadataHeroSection.Summary -> Column(
|
||||
verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
) {
|
||||
item.taglines
|
||||
.firstOrNull { item.isMovie && it.isNotBlank() }
|
||||
?.let { tagline ->
|
||||
Text(
|
||||
tagline,
|
||||
color = EmbyGreen,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val overview = item.overview
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: "No description available."
|
||||
val episodePrefix = if (isContinueWatchingItem && item.isEpisode) {
|
||||
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
episodePrefix?.let { prefix ->
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(prefix)
|
||||
}
|
||||
append(" — ")
|
||||
}
|
||||
append(overview)
|
||||
},
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = if (compact) 2 else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MetadataStatus(item)
|
||||
@@ -328,7 +373,13 @@ internal fun metadataHeroFacts(item: BaseItem): List<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,41 +464,12 @@ internal fun SearchQueryField(
|
||||
) { startVoiceSearch() }
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MembySurfaceRaised)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
MembyIcon.Search.mark,
|
||||
contentDescription = null,
|
||||
tint = if (query.isEmpty()) Muted else Accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = query.ifEmpty { placeholder },
|
||||
color = if (query.isEmpty()) Muted else Heading,
|
||||
// Fixed size rather than shrinking as the query grows: readable at three
|
||||
// metres matters more than fitting a long query on one line.
|
||||
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
|
||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (loading) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
LoadingDot()
|
||||
}
|
||||
if (voiceAvailable) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onFocused = onVoiceFocused,
|
||||
onClick = {
|
||||
// Asked at the point of use rather than during setup: the microphone is
|
||||
// only ever wanted by this one button, and a permission dialog in front
|
||||
@@ -503,10 +493,44 @@ internal fun SearchQueryField(
|
||||
tint = if (focused) KeyLabelFocused else KeyLabel,
|
||||
modifier = Modifier
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(6.dp)
|
||||
.size(18.dp),
|
||||
.padding(11.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MembySurfaceRaised)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
MembyIcon.Search.mark,
|
||||
contentDescription = null,
|
||||
tint = if (query.isEmpty()) Muted else Accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = query.ifEmpty { placeholder },
|
||||
color = if (query.isEmpty()) Muted else Heading,
|
||||
// Fixed size rather than shrinking as the query grows: readable at three
|
||||
// metres matters more than fitting a long query on one line.
|
||||
fontSize = if (query.isEmpty()) 12.sp else 17.sp,
|
||||
fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
// A long query scrolls off the *start*, so the letters just typed stay visible.
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (loading) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
LoadingDot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package com.ponzischeme89.memby.ui.search.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -8,10 +15,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -23,10 +32,12 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.MembyChoiceChip
|
||||
@@ -37,8 +48,10 @@ import com.ponzischeme89.memby.ui.search.SearchUiState
|
||||
import com.ponzischeme89.memby.ui.search.shouldSearch
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
/** Search-owned result state rendered through the same media row as My Requests. */
|
||||
@@ -57,6 +70,7 @@ internal fun SearchResults(
|
||||
onRetry: () -> Unit,
|
||||
onShowRequests: () -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
voiceAvailable: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val items = state.results.filter { it.isMovie || it.isSeries }
|
||||
@@ -73,15 +87,18 @@ internal fun SearchResults(
|
||||
}
|
||||
}
|
||||
|
||||
val showStartPrompt = state.isDiscovery && items.isEmpty()
|
||||
Column(modifier) {
|
||||
SearchResultsHeading(
|
||||
state = state,
|
||||
resultCount = items.size,
|
||||
onShowRequests = onShowRequests,
|
||||
requestFocusRequester = entryFocusRequester,
|
||||
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (!showStartPrompt) {
|
||||
SearchResultsHeading(
|
||||
state = state,
|
||||
resultCount = items.size,
|
||||
onShowRequests = onShowRequests,
|
||||
requestFocusRequester = entryFocusRequester,
|
||||
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
when {
|
||||
state.isLoading && items.isEmpty() -> Column(
|
||||
@@ -100,6 +117,8 @@ internal fun SearchResults(
|
||||
keyboardReturnFocusRequester = keyboardReturnFocusRequester,
|
||||
)
|
||||
|
||||
showStartPrompt -> SearchStartPrompt(voiceAvailable)
|
||||
|
||||
items.isEmpty() -> SearchResultMessage(message = emptyMessage(state))
|
||||
|
||||
else -> LazyColumn(
|
||||
@@ -163,6 +182,54 @@ internal fun SearchResults(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchStartPrompt(voiceAvailable: Boolean) {
|
||||
val pulse = rememberInfiniteTransition(label = "search-start-prompt").animateFloat(
|
||||
initialValue = 0.96f,
|
||||
targetValue = 1.04f,
|
||||
animationSpec = infiniteRepeatable(tween(1_200), RepeatMode.Reverse),
|
||||
label = "search-start-prompt-scale",
|
||||
)
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(54.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = pulse.value
|
||||
scaleY = pulse.value
|
||||
}
|
||||
.background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (voiceAvailable) MembyIcon.Mic.mark else MembyIcon.Search.mark,
|
||||
contentDescription = null,
|
||||
tint = MembyAccent,
|
||||
modifier = Modifier.size(25.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = if (voiceAvailable) {
|
||||
"Use your voice or type a couple of letters"
|
||||
} else {
|
||||
"Type a couple of letters to begin"
|
||||
},
|
||||
color = MembyOnSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"Movies, TV series and episodes are all searchable.",
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultsHeading(
|
||||
state: SearchUiState,
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user