Player overlay v2: now/next with programme progress, audio track switching

- Overlay shows current programme (title, time range, EPG progress) and
  what's next; resurfaces briefly on programme change, hides after 5s.
- Audio tracks: listed from the stream, switchable via D-pad left/right
  on live streams (seek keeps left/right on seekable media), via chips
  in the overlay and from the phone remote (set_audio message).
- Main UI font switches to Inter; Space Grotesk stays for the wordmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:54:02 +02:00
parent ce6c8b5735
commit 081a552400
16 changed files with 270 additions and 55 deletions

View File

@@ -71,6 +71,14 @@
.btn-main:disabled { opacity: .35; box-shadow: none; }
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
.audiorow { display: none; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 8px 0; }
.audiorow.on { display: flex; }
.audiorow .lbl { color: #6b717a; font-size: 11px; letter-spacing: 2px; margin-right: 4px; }
.achip {
border: 1px solid #23262b; background: transparent; color: #9aa0a8;
border-radius: 999px; padding: 7px 14px; font-size: 13px; font-family: inherit;
}
.achip.sel { background: #5fd4c4; border-color: #5fd4c4; color: #07110f; font-weight: 600; }
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: #1e2126; outline: none; }
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
@@ -210,6 +218,7 @@
<input type="range" id="volume" min="0" max="100" value="50" aria-label="Lautstärke">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b717a" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/><path d="M18.4 5.6a9 9 0 010 12.8"/></svg>
</div>
<div class="audiorow" id="audiorow"><span class="lbl">TON</span></div>
</section>
<!-- Sender -->
@@ -411,6 +420,19 @@
if (!state.volumeDragging && typeof s.volume === 'number') {
$('volume').value = Math.round(s.volume * 100);
}
const tracks = s.audioTracks || [];
const row = $('audiorow');
row.classList.toggle('on', hasMedia && tracks.length > 1);
while (row.children.length > 1) row.removeChild(row.lastChild);
if (tracks.length > 1) {
tracks.forEach((label, i) => {
const chip = document.createElement('button');
chip.className = 'achip' + (i === s.audioSelected ? ' sel' : '');
chip.textContent = label;
chip.addEventListener('click', () => send({ type: 'set_audio', index: i }));
row.appendChild(chip);
});
}
}
function renderChannels() {

View File

@@ -38,6 +38,9 @@ class AppState(
var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null)
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set
private var lastNowTitle: String? = null
/** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
@@ -158,13 +161,31 @@ class AppState(
}
fun syncFromPlayer() {
val previous = playerState
playerState = player.state
if (playerState != previous) overlayVisible = true
isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.coerceAtLeast(0)
audioTracks = player.audioTracks()
// Briefly resurface the overlay when the running programme changes.
val nowTitle = currentChannel?.let { nowNext(it).now?.title }
if (playerVisible && nowTitle != null && lastNowTitle != null && nowTitle != lastNowTitle) {
overlayVisible = true
}
lastNowTitle = nowTitle
if (!player.hasMedia && playerVisible) {
playerVisible = false
currentChannel = null
}
}
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
fun cycleAudio(direction: Int) {
val tracks = audioTracks
if (tracks.size < 2) return
val current = tracks.indexOfFirst { it.selected }.coerceAtLeast(0)
player.selectAudio((current + direction + tracks.size) % tracks.size)
audioTracks = player.audioTracks()
}
}

View File

@@ -115,6 +115,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", player.errorMessage ?: "")
.put("audioTracks", org.json.JSONArray().also { arr ->
state.audioTracks.forEach { arr.put(it.label) }
})
.put("audioSelected", state.audioTracks.indexOfFirst { it.selected })
}
override fun currentChannels(): List<Channel> = state.activeChannels()
@@ -224,14 +228,18 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1)
return true
}
// Live streams are not seekable — there left/right cycles the
// audio track instead (overlay shows what happened).
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS)
if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else cycleAudioWithOverlay(-1)
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS)
if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
else cycleAudioWithOverlay(1)
return true
}
KeyEvent.KEYCODE_BACK -> {
@@ -244,6 +252,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return super.onKeyDown(keyCode, event)
}
private fun cycleAudioWithOverlay(direction: Int) {
state.overlayVisible = true
state.cycleAudio(direction)
server.broadcastStatus()
}
override fun onSetAudio(index: Int) {
if (index < 0) return
state.player.selectAudio(index)
state.syncFromPlayer()
state.overlayVisible = true
server.broadcastStatus()
}
// --- lifecycle ---
override fun onStop() {

View File

@@ -1,12 +1,16 @@
package dev.castarr.tv.player
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import java.util.Locale
/**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
@@ -30,12 +34,17 @@ class PlayerController(
private var retryCount = 0
private var lastUrl: String = ""
/** One selectable audio track of the current stream. */
data class AudioTrack(val label: String, val selected: Boolean)
init {
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onTracksChanged(tracks: Tracks) = onChanged()
override fun onPlayerError(error: PlaybackException) {
// Self-heal: silently reconnect twice before surfacing (#13).
if (retryCount < 2 && lastUrl.isNotEmpty()) {
@@ -105,6 +114,48 @@ class PlayerController(
onChanged()
}
/** Audio tracks of the current stream, in group order. */
fun audioTracks(): List<AudioTrack> {
val result = mutableListOf<AudioTrack>()
val seen = mutableSetOf<String>()
player.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_AUDIO }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
var label = format.label
?: format.language?.let { Locale(it).getDisplayLanguage(Locale.GERMAN) }
?.replaceFirstChar { c -> c.uppercase() }
?: "Ton ${result.size + 1}"
if (!seen.add(label)) label = "$label ${result.size + 1}"
result.add(AudioTrack(label, group.isTrackSelected(i)))
}
}
return result
}
/** Selects the audio track at [index] of [audioTracks]'s order. */
fun selectAudio(index: Int) {
var flat = 0
player.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_AUDIO }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
if (flat == index) {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, i))
.build()
onChanged()
return
}
flat++
}
}
}
fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000)

View File

@@ -32,6 +32,7 @@ class ControlServer(
fun onResumeCast()
fun onStopCast()
fun onSeek(deltaSeconds: Long)
fun onSetAudio(index: Int)
fun onVolume(value: Float)
fun onSetPlaylist(url: String)
fun onToggleFavorite(channelId: Int)
@@ -201,6 +202,7 @@ class ControlServer(
"resume" -> post { listener.onResumeCast() }
"stop" -> post { listener.onStopCast() }
"seek" -> post { listener.onSeek(msg.optLong("delta")) }
"set_audio" -> post { listener.onSetAudio(msg.optInt("index", -1)) }
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }

View File

@@ -46,18 +46,18 @@ fun AdvancedScreen(state: AppState) {
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp)
) {
Text("Erweitert", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 22.sp)
Text("Erweitert", color = CastarrColors.fg, fontFamily = AppFont, fontSize = 22.sp)
Spacer(Modifier.height(4.dp))
Text(
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
"Server: ${state.auth.serverUrl.ifEmpty { "—" }}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
Spacer(Modifier.height(18.dp))
TvTextField(
value = m3u,
onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) },
label = { Text("M3U-URL", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
@@ -66,7 +66,7 @@ fun AdvancedScreen(state: AppState) {
TvTextField(
value = epg,
onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
@@ -92,7 +92,7 @@ fun AdvancedScreen(state: AppState) {
else -> message
}
if (statusText.isNotEmpty()) {
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
Text(statusText, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
}
}
}

View File

@@ -83,7 +83,7 @@ private fun TopBar(state: AppState) {
.background(CastarrColors.accent)
)
Spacer(Modifier.width(8.dp))
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp)
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
}
}
}
@@ -103,7 +103,7 @@ private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
) {
Text(
label,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),

View File

@@ -55,11 +55,11 @@ fun ErrorScreen(state: AppState) {
"Neu anmelden",
)
}
Text(title, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 30.sp)
Text(title, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 30.sp)
Spacer(Modifier.height(10.dp))
Text(
subtitle,
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 15.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
@@ -78,7 +78,7 @@ fun ErrorScreen(state: AppState) {
) {
Text(
buttonLabel,
fontFamily = SpaceGrotesk, fontSize = 16.sp,
fontFamily = AppFont, fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
)
}

View File

@@ -59,7 +59,7 @@ fun LiveScreen(state: AppState) {
Text(
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 15.sp,
)
}
@@ -129,7 +129,7 @@ private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
) {
Text(
label,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
@@ -166,7 +166,7 @@ private fun ChannelRow(
Text(
"${index + 1}",
color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(44.dp),
@@ -183,7 +183,7 @@ private fun ChannelRow(
Text(
channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 16.sp,
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
@@ -193,7 +193,7 @@ private fun ChannelRow(
Text(
channel.group,
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -222,7 +222,7 @@ private fun NowNextCell(nowNext: NowNext) {
Text(
now.title,
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -249,7 +249,7 @@ private fun NowNextCell(nowNext: NowNext) {
Text(
"danach: ${next.title}",
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View File

@@ -1,7 +1,7 @@
package dev.castarr.tv.ui
import android.view.KeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -33,21 +33,23 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Fullscreen playback with the minimal auto-hiding overlay. Key handling for
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only
* grabs focus so list items underneath stop reacting.
* play/pause/zapping/audio lives in MainActivity.onKeyDown; this composable
* only grabs focus so list items underneath stop reacting.
*/
@Composable
fun PlayerScreen(state: AppState) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(state.playerVisible, state.playerState) {
state.overlayVisible = true
if (state.playerState == "playing") {
kotlinx.coroutines.delay(4000)
LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible) {
if (state.overlayVisible && state.playerState == "playing") {
kotlinx.coroutines.delay(5000)
state.overlayVisible = false
}
}
@@ -76,17 +78,20 @@ fun PlayerScreen(state: AppState) {
}
}
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
@Composable
private fun Overlay(state: AppState) {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxWidth()
.height(200.dp)
.height(240.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.92f))
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.94f))
)
)
)
@@ -97,7 +102,7 @@ private fun Overlay(state: AppState) {
.padding(top = 22.dp, end = 30.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp)
Spacer(Modifier.width(8.dp))
Box(
Modifier
@@ -112,7 +117,7 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 36.dp)
.padding(bottom = 30.dp),
.padding(bottom = 26.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.isLive) {
@@ -126,7 +131,7 @@ private fun Overlay(state: AppState) {
Text(
"LIVE",
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 11.sp,
letterSpacing = 2.sp,
)
@@ -140,7 +145,7 @@ private fun Overlay(state: AppState) {
else -> "Wird abgespielt"
},
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
@@ -148,27 +153,110 @@ private fun Overlay(state: AppState) {
Text(
state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 30.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(16.dp))
val fraction = if (state.isLive || state.durationMs <= 0) 1f
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now
if (now != null) {
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 18.sp,
)
Spacer(Modifier.width(14.dp))
Text(
"${formatClock(now.start)} ${formatClock(now.stop)}",
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 14.sp,
)
}
}
info?.next?.let { next ->
Spacer(Modifier.height(2.dp))
Text(
"Danach: ${next.title} (${formatClock(next.start)})",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
)
}
Spacer(Modifier.height(12.dp))
// Live with EPG: progress of the running programme. Otherwise
// playback position; unknown duration fills the bar.
val fraction = when {
now != null && now.stop > now.start ->
((System.currentTimeMillis() - now.start).toFloat() / (now.stop - now.start))
.coerceIn(0f, 1f)
state.isLive || state.durationMs <= 0 -> 1f
else -> (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
}
Box(
Modifier
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.height(3.dp)
.clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line)
) {
Box(
Modifier
.fillMaxWidth(fraction)
.height(2.dp)
.height(3.dp)
.background(CastarrColors.accent)
)
}
if (state.audioTracks.size > 1) {
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"TON",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
letterSpacing = 2.sp,
)
Spacer(Modifier.width(12.dp))
state.audioTracks.forEach { track ->
Box(
Modifier
.padding(end = 8.dp)
.clip(RoundedCornerShape(999.dp))
.background(
if (track.selected) CastarrColors.accent else Color.Transparent
)
.border(
width = 1.dp,
color = if (track.selected) CastarrColors.accent else CastarrColors.line,
shape = RoundedCornerShape(999.dp),
)
) {
Text(
track.label,
color = if (track.selected) CastarrColors.onAccent else CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
fontWeight = if (track.selected) FontWeight.Medium else FontWeight.Normal,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 5.dp),
)
}
}
Spacer(Modifier.weight(1f))
Text(
"◀ ▶ wechselt die Tonspur",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
)
}
}
}
}
}

View File

@@ -59,7 +59,7 @@ fun SettingsScreen(state: AppState) {
if (state.auth.isLoggedIn) {
Text(
"Angemeldet als ${state.auth.username}",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
)
Spacer(Modifier.height(14.dp))
if (state.source.m3uUrl.isNotEmpty()) {
@@ -74,7 +74,7 @@ fun SettingsScreen(state: AppState) {
}
Spacer(Modifier.height(14.dp))
}
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
Row {
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
@@ -90,7 +90,7 @@ fun SettingsScreen(state: AppState) {
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
)
Spacer(Modifier.height(12.dp))
ActionButton("Jetzt anmelden") { state.startOnboarding() }
@@ -101,7 +101,7 @@ fun SettingsScreen(state: AppState) {
Spacer(Modifier.height(8.dp))
Text(
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
Spacer(Modifier.height(8.dp))
Row {
@@ -124,7 +124,7 @@ fun SettingsScreen(state: AppState) {
}
if (updateStatus.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(updateStatus, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Text(updateStatus, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp)
}
}
@@ -142,10 +142,10 @@ fun SettingsScreen(state: AppState) {
Spacer(Modifier.height(10.dp))
Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp)
}
}
}
@@ -153,7 +153,7 @@ fun SettingsScreen(state: AppState) {
@Composable
private fun SectionTitle(text: String) {
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
Text(text, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 20.sp)
}
@Composable
@@ -170,7 +170,7 @@ fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
) {
Text(
label,
fontFamily = SpaceGrotesk, fontSize = 12.sp,
fontFamily = AppFont, fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
@@ -190,7 +190,7 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
) {
Text(
label,
fontFamily = SpaceGrotesk, fontSize = 13.sp,
fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
)
}

View File

@@ -21,6 +21,15 @@ object CastarrColors {
val onAccent = Color(0xFF07110F)
}
/** Display face — only for the CASTARR wordmark. */
val SpaceGrotesk = FontFamily(
Font(R.font.space_grotesk, FontWeight.Normal),
)
/** UI face for everything else (Inter). */
val AppFont = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_semibold, FontWeight.Bold),
)

View File

@@ -49,7 +49,7 @@ fun WelcomeScreen(state: AppState) {
Column(Modifier.weight(1f)) {
Text(
"Willkommen bei Castarr",
color = CastarrColors.fg, fontFamily = SpaceGrotesk,
color = CastarrColors.fg, fontFamily = AppFont,
fontSize = 32.sp, fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(28.dp))
@@ -90,11 +90,11 @@ fun WelcomeScreen(state: AppState) {
Spacer(Modifier.height(12.dp))
Text(
Pairing.remoteUrl(address),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp,
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
}
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 15.sp)
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 15.sp)
}
}
}
@@ -116,19 +116,19 @@ private fun Step(number: Int, text: String, active: Boolean, done: Boolean) {
Text(
if (done) "" else "$number",
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
fontFamily = SpaceGrotesk, fontSize = 14.sp, fontWeight = FontWeight.Medium,
fontFamily = AppFont, fontSize = 14.sp, fontWeight = FontWeight.Medium,
)
}
Spacer(Modifier.width(14.dp))
Text(
text,
color = if (active) CastarrColors.fg else CastarrColors.muted,
fontFamily = SpaceGrotesk, fontSize = 17.sp,
fontFamily = AppFont, fontSize = 17.sp,
)
}
}
@Composable
private fun Status(text: String) {
Text(text, color = CastarrColors.accent, fontFamily = SpaceGrotesk, fontSize = 14.sp)
Text(text, color = CastarrColors.accent, fontFamily = AppFont, fontSize = 14.sp)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.