feat(audio-rooms): font-tag parser + system-font typography (T3 #1)
Closes the deferred font tag from the Tier-3 plan. Wire-up:
* quartz: FontTag(family, optionalUrl) parser/assembler with
blank-rejection on family + blank-URL-becomes-null normalisation.
* MeetingSpaceEvent.font(): FontTag? accessor.
* TagArrayBuilder.font(family, url) DSL.
* RoomTheme gains fontFamily / fontUrl fields.
* AudioRoomThemedScope maps `family` to a Compose FontFamily for
the four CSS-style generic-family names ("sans-serif", "serif",
"monospace", "cursive") — each maps to the corresponding system
fallback. The whole Material3 typography is rebuilt with that
family so headlines / body / labels all swap together.
URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.
Tests:
* FontTagTest — 9 cases covering family-only, family+URL,
blank rejection, missing family, wrong tag name, blank-URL
normalisation, assembler shapes (with + without URL), and
the assembler's blank-family rejection.
* RoomThemeTest gains 3 cases for font projection (family-only /
family+URL / empty event).
This commit is contained in:
+56
-1
@@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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.text.font.FontFamily
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
|
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
|
||||||
|
|
||||||
@@ -62,7 +64,18 @@ internal fun AudioRoomThemedScope(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(colorScheme = themed) {
|
val originalTypography = MaterialTheme.typography
|
||||||
|
val themedTypography =
|
||||||
|
remember(theme.fontFamily, originalTypography) {
|
||||||
|
val family = systemFontFamilyFor(theme.fontFamily)
|
||||||
|
if (family == null) {
|
||||||
|
originalTypography
|
||||||
|
} else {
|
||||||
|
applyFontFamily(originalTypography, family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(colorScheme = themed, typography = themedTypography) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
theme.backgroundImageUrl?.let { url ->
|
theme.backgroundImageUrl?.let { url ->
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@@ -85,3 +98,45 @@ internal fun AudioRoomThemedScope(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a kind-30312 `["f", family]` value to a Compose [FontFamily].
|
||||||
|
* Only the four CSS-style generic-family names are honoured today —
|
||||||
|
* arbitrary names (e.g. "Inter") fall back to platform default
|
||||||
|
* unless a downloader is wired up. URL-based loading
|
||||||
|
* (RoomTheme.fontUrl) is the natural follow-up: fetch + cache
|
||||||
|
* via OkHttp, then build a FontFamily from a local file. Until
|
||||||
|
* then, an unknown family is silently a no-op.
|
||||||
|
*/
|
||||||
|
private fun systemFontFamilyFor(name: String?): FontFamily? =
|
||||||
|
when (name?.trim()?.lowercase()) {
|
||||||
|
null -> null
|
||||||
|
"" -> null
|
||||||
|
"sans-serif", "sans serif", "sansserif" -> FontFamily.SansSerif
|
||||||
|
"serif" -> FontFamily.Serif
|
||||||
|
"monospace", "mono" -> FontFamily.Monospace
|
||||||
|
"cursive" -> FontFamily.Cursive
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFontFamily(
|
||||||
|
typography: Typography,
|
||||||
|
family: FontFamily,
|
||||||
|
): Typography =
|
||||||
|
typography.copy(
|
||||||
|
displayLarge = typography.displayLarge.copy(fontFamily = family),
|
||||||
|
displayMedium = typography.displayMedium.copy(fontFamily = family),
|
||||||
|
displaySmall = typography.displaySmall.copy(fontFamily = family),
|
||||||
|
headlineLarge = typography.headlineLarge.copy(fontFamily = family),
|
||||||
|
headlineMedium = typography.headlineMedium.copy(fontFamily = family),
|
||||||
|
headlineSmall = typography.headlineSmall.copy(fontFamily = family),
|
||||||
|
titleLarge = typography.titleLarge.copy(fontFamily = family),
|
||||||
|
titleMedium = typography.titleMedium.copy(fontFamily = family),
|
||||||
|
titleSmall = typography.titleSmall.copy(fontFamily = family),
|
||||||
|
bodyLarge = typography.bodyLarge.copy(fontFamily = family),
|
||||||
|
bodyMedium = typography.bodyMedium.copy(fontFamily = family),
|
||||||
|
bodySmall = typography.bodySmall.copy(fontFamily = family),
|
||||||
|
labelLarge = typography.labelLarge.copy(fontFamily = family),
|
||||||
|
labelMedium = typography.labelMedium.copy(fontFamily = family),
|
||||||
|
labelSmall = typography.labelSmall.copy(fontFamily = family),
|
||||||
|
)
|
||||||
|
|||||||
+28
-1
@@ -43,11 +43,35 @@ data class RoomTheme(
|
|||||||
val primaryArgb: Long?,
|
val primaryArgb: Long?,
|
||||||
val backgroundImageUrl: String?,
|
val backgroundImageUrl: String?,
|
||||||
val backgroundMode: BackgroundMode,
|
val backgroundMode: BackgroundMode,
|
||||||
|
/**
|
||||||
|
* Suggested font family from the kind-30312 `["f", family,
|
||||||
|
* optionalUrl]` tag. Renderers map this to a platform
|
||||||
|
* FontFamily — the system-font shorthand list ("sans-serif",
|
||||||
|
* "serif", "monospace", "cursive") is honoured directly;
|
||||||
|
* other names fall back to the platform default unless the
|
||||||
|
* renderer also fetches [fontUrl].
|
||||||
|
*/
|
||||||
|
val fontFamily: String?,
|
||||||
|
/**
|
||||||
|
* Optional URL to a font file (e.g. WOFF2). The protocol
|
||||||
|
* permits async loading; clients without a font-fetch loader
|
||||||
|
* stay on [fontFamily] alone (or default typography on miss).
|
||||||
|
*/
|
||||||
|
val fontUrl: String?,
|
||||||
) {
|
) {
|
||||||
enum class BackgroundMode { COVER, TILE }
|
enum class BackgroundMode { COVER, TILE }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val Empty = RoomTheme(null, null, null, null, BackgroundMode.COVER)
|
val Empty =
|
||||||
|
RoomTheme(
|
||||||
|
backgroundArgb = null,
|
||||||
|
textArgb = null,
|
||||||
|
primaryArgb = null,
|
||||||
|
backgroundImageUrl = null,
|
||||||
|
backgroundMode = BackgroundMode.COVER,
|
||||||
|
fontFamily = null,
|
||||||
|
fontUrl = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project the theme tags off a kind-30312 event. Returns
|
* Project the theme tags off a kind-30312 event. Returns
|
||||||
@@ -57,6 +81,7 @@ data class RoomTheme(
|
|||||||
fun from(event: MeetingSpaceEvent): RoomTheme {
|
fun from(event: MeetingSpaceEvent): RoomTheme {
|
||||||
val colors = event.colors()
|
val colors = event.colors()
|
||||||
val bg = event.background()
|
val bg = event.background()
|
||||||
|
val font = event.font()
|
||||||
|
|
||||||
fun pickHex(target: ColorTag.Target): Long? =
|
fun pickHex(target: ColorTag.Target): Long? =
|
||||||
colors
|
colors
|
||||||
@@ -74,6 +99,8 @@ data class RoomTheme(
|
|||||||
BackgroundTag.Mode.TILE -> BackgroundMode.TILE
|
BackgroundTag.Mode.TILE -> BackgroundMode.TILE
|
||||||
else -> BackgroundMode.COVER
|
else -> BackgroundMode.COVER
|
||||||
},
|
},
|
||||||
|
fontFamily = font?.family,
|
||||||
|
fontUrl = font?.url,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
@@ -128,4 +128,28 @@ class RoomThemeTest {
|
|||||||
assertEquals(0xFF000000L, RoomTheme.hexToOpaqueArgb("000000"))
|
assertEquals(0xFF000000L, RoomTheme.hexToOpaqueArgb("000000"))
|
||||||
assertEquals(0xFFFFFFFFL, RoomTheme.hexToOpaqueArgb("FFFFFF"))
|
assertEquals(0xFFFFFFFFL, RoomTheme.hexToOpaqueArgb("FFFFFF"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fontFamilyOnly() {
|
||||||
|
val theme = RoomTheme.from(event(listOf(arrayOf("f", "Inter"))))
|
||||||
|
assertEquals("Inter", theme.fontFamily)
|
||||||
|
assertNull(theme.fontUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fontFamilyAndUrl() {
|
||||||
|
val theme =
|
||||||
|
RoomTheme.from(
|
||||||
|
event(listOf(arrayOf("f", "Inter", "https://fonts.example/inter.woff2"))),
|
||||||
|
)
|
||||||
|
assertEquals("Inter", theme.fontFamily)
|
||||||
|
assertEquals("https://fonts.example/inter.woff2", theme.fontUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyFontDefaultsBothNull() {
|
||||||
|
val theme = RoomTheme.from(event(emptyList()))
|
||||||
|
assertNull(theme.fontFamily)
|
||||||
|
assertNull(theme.fontUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -85,6 +85,14 @@ class MeetingSpaceEvent(
|
|||||||
*/
|
*/
|
||||||
fun background() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.BackgroundTag::parse)
|
fun background() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.BackgroundTag::parse)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggested typography for the room screen. Returns the first
|
||||||
|
* `["f", family, optionalUrl]` tag. Clients with theming
|
||||||
|
* support match the family against system fonts (or fetch the
|
||||||
|
* URL); clients without theming support ignore.
|
||||||
|
*/
|
||||||
|
fun font() = tags.firstNotNullOfOrNull(com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag::parse)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled start time as unix seconds, only meaningful when
|
* Scheduled start time as unix seconds, only meaningful when
|
||||||
* [status] is [StatusTag.STATUS.PLANNED]. Returns null on
|
* [status] is [StatusTag.STATUS.PLANNED]. Returns null on
|
||||||
|
|||||||
+8
@@ -53,6 +53,14 @@ fun TagArrayBuilder<MeetingSpaceEvent>.starts(unixSeconds: Long) =
|
|||||||
.assemble(unixSeconds),
|
.assemble(unixSeconds),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.font(
|
||||||
|
family: String,
|
||||||
|
url: String? = null,
|
||||||
|
) = addUnique(
|
||||||
|
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.FontTag
|
||||||
|
.assemble(family, url),
|
||||||
|
)
|
||||||
|
|
||||||
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
|
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
|
||||||
|
|
||||||
fun TagArrayBuilder<MeetingSpaceEvent>.participant(
|
fun TagArrayBuilder<MeetingSpaceEvent>.participant(
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `["f", family, optionalUrl]` on a kind-30312 audio-room event.
|
||||||
|
* The host suggests a font family for the room screen; clients
|
||||||
|
* with theming support load it (URL → fetch font file; bare
|
||||||
|
* family → match against a built-in / system font name with the
|
||||||
|
* client's platform fallback). Clients without theming support
|
||||||
|
* ignore the tag.
|
||||||
|
*
|
||||||
|
* The URL component is optional and is the only spec-defined way
|
||||||
|
* to request a non-system font (e.g. a Google-Fonts WOFF). When
|
||||||
|
* present, the URL must be HTTPS — the parser does not enforce
|
||||||
|
* this (a renderer is free to decline `http://` for security
|
||||||
|
* reasons), but invalid / blank family values are rejected so
|
||||||
|
* the renderer can trust the parsed shape.
|
||||||
|
*/
|
||||||
|
class FontTag(
|
||||||
|
val family: String,
|
||||||
|
val url: String?,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TAG_NAME = "f"
|
||||||
|
|
||||||
|
fun parse(tag: Array<String>): FontTag? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
val family = tag[1]
|
||||||
|
ensure(family.isNotBlank()) { return null }
|
||||||
|
val url = tag.getOrNull(2)?.takeIf { it.isNotBlank() }
|
||||||
|
return FontTag(family, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(
|
||||||
|
family: String,
|
||||||
|
url: String? = null,
|
||||||
|
): Array<String> {
|
||||||
|
require(family.isNotBlank()) { "font: family must not be blank" }
|
||||||
|
return if (url != null) arrayOf(TAG_NAME, family, url) else arrayOf(TAG_NAME, family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class FontTagTest {
|
||||||
|
@Test
|
||||||
|
fun parsesFamilyOnly() {
|
||||||
|
val parsed = FontTag.parse(arrayOf("f", "Inter"))
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("Inter", parsed.family)
|
||||||
|
assertNull(parsed.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesFamilyWithUrl() {
|
||||||
|
val parsed = FontTag.parse(arrayOf("f", "Inter", "https://fonts.example/inter.woff2"))
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("Inter", parsed.family)
|
||||||
|
assertEquals("https://fonts.example/inter.woff2", parsed.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsBlankFamily() {
|
||||||
|
// A blank family is not actionable for the renderer — match
|
||||||
|
// ColorTag's strictness rather than letting an empty value
|
||||||
|
// through and surprising the typography fallback.
|
||||||
|
assertNull(FontTag.parse(arrayOf("f", "")))
|
||||||
|
assertNull(FontTag.parse(arrayOf("f", " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsMissingFamily() {
|
||||||
|
assertNull(FontTag.parse(arrayOf("f")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsWrongName() {
|
||||||
|
assertNull(FontTag.parse(arrayOf("font", "Inter")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blankUrlBecomesNull() {
|
||||||
|
// A blank third element is treated as "no URL" rather than
|
||||||
|
// an empty URL — otherwise the renderer would attempt an
|
||||||
|
// HTTP fetch for "" and fail at the URL parser.
|
||||||
|
val parsed = FontTag.parse(arrayOf("f", "Inter", ""))
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertNull(parsed.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assembleFamilyOnly() {
|
||||||
|
assertEquals(
|
||||||
|
arrayOf("f", "Inter").toList(),
|
||||||
|
FontTag.assemble("Inter").toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assembleWithUrl() {
|
||||||
|
assertEquals(
|
||||||
|
arrayOf("f", "Inter", "https://fonts.example/inter.woff2").toList(),
|
||||||
|
FontTag.assemble("Inter", "https://fonts.example/inter.woff2").toList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assembleRejectsBlankFamily() {
|
||||||
|
assertFailsWith<IllegalArgumentException> { FontTag.assemble("") }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user