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