diff --git a/app/src/main/assets/remote/index.html b/app/src/main/assets/remote/index.html index 8debde7..a9aaa41 100644 --- a/app/src/main/assets/remote/index.html +++ b/app/src/main/assets/remote/index.html @@ -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 @@ +
TON
@@ -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() { diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt index cc06ae2..8037788 100644 --- a/app/src/main/java/dev/castarr/tv/AppState.kt +++ b/app/src/main/java/dev/castarr/tv/AppState.kt @@ -38,6 +38,9 @@ class AppState( var isLive by mutableStateOf(false) var favoritesOnly by mutableStateOf(false) var groupFilter by mutableStateOf(null) + var audioTracks by mutableStateOf>(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() + } } diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt index 4c167d4..501f267 100644 --- a/app/src/main/java/dev/castarr/tv/MainActivity.kt +++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt @@ -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 = 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() { diff --git a/app/src/main/java/dev/castarr/tv/player/PlayerController.kt b/app/src/main/java/dev/castarr/tv/player/PlayerController.kt index e878e6e..dde6f38 100644 --- a/app/src/main/java/dev/castarr/tv/player/PlayerController.kt +++ b/app/src/main/java/dev/castarr/tv/player/PlayerController.kt @@ -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 { + val result = mutableListOf() + val seen = mutableSetOf() + 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) diff --git a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt index 1dddf55..b8fc82e 100644 --- a/app/src/main/java/dev/castarr/tv/server/ControlServer.kt +++ b/app/src/main/java/dev/castarr/tv/server/ControlServer.kt @@ -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")) } diff --git a/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt b/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt index 7b18705..4a5bf6d 100644 --- a/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt @@ -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) } } } diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt index 81a3d39..abe582f 100644 --- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt +++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt @@ -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), diff --git a/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt b/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt index 7445174..d09a46d 100644 --- a/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/ErrorScreen.kt @@ -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), ) } diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt index 65bc422..3c70e88 100644 --- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt @@ -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, diff --git a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt index 0ea7518..680f45c 100644 --- a/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/PlayerScreen.kt @@ -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, + ) + } + } } } } diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt index 62aa58f..953ccf7 100644 --- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt @@ -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), ) } diff --git a/app/src/main/java/dev/castarr/tv/ui/Theme.kt b/app/src/main/java/dev/castarr/tv/ui/Theme.kt index 79a3348..b57b85a 100644 --- a/app/src/main/java/dev/castarr/tv/ui/Theme.kt +++ b/app/src/main/java/dev/castarr/tv/ui/Theme.kt @@ -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), +) diff --git a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt index b39dade..fb8d53e 100644 --- a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt +++ b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt @@ -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) } diff --git a/app/src/main/res/font/inter_medium.ttf b/app/src/main/res/font/inter_medium.ttf new file mode 100644 index 0000000..4a57a1a Binary files /dev/null and b/app/src/main/res/font/inter_medium.ttf differ diff --git a/app/src/main/res/font/inter_regular.ttf b/app/src/main/res/font/inter_regular.ttf new file mode 100644 index 0000000..399a6e0 Binary files /dev/null and b/app/src/main/res/font/inter_regular.ttf differ diff --git a/app/src/main/res/font/inter_semibold.ttf b/app/src/main/res/font/inter_semibold.ttf new file mode 100644 index 0000000..67fda28 Binary files /dev/null and b/app/src/main/res/font/inter_semibold.ttf differ