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; } .btn-main:disabled { opacity: .35; box-shadow: none; }
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; } .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; input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: #1e2126; outline: none; } border-radius: 2px; background: #1e2126; outline: none; }
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: 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"> <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> <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>
<div class="audiorow" id="audiorow"><span class="lbl">TON</span></div>
</section> </section>
<!-- Sender --> <!-- Sender -->
@@ -411,6 +420,19 @@
if (!state.volumeDragging && typeof s.volume === 'number') { if (!state.volumeDragging && typeof s.volume === 'number') {
$('volume').value = Math.round(s.volume * 100); $('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() { function renderChannels() {

View File

@@ -38,6 +38,9 @@ class AppState(
var isLive by mutableStateOf(false) var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false) var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null) 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). */ /** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
@@ -158,13 +161,31 @@ class AppState(
} }
fun syncFromPlayer() { fun syncFromPlayer() {
val previous = playerState
playerState = player.state playerState = player.state
if (playerState != previous) overlayVisible = true
isLive = player.player.isCurrentMediaItemLive isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0) positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.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) { if (!player.hasMedia && playerVisible) {
playerVisible = false playerVisible = false
currentChannel = null 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("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0) .put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", player.errorMessage ?: "") .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() override fun currentChannels(): List<Channel> = state.activeChannels()
@@ -224,14 +228,18 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1) state.zap(1)
return true 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_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> { KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else cycleAudioWithOverlay(-1)
return true return true
} }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
else cycleAudioWithOverlay(1)
return true return true
} }
KeyEvent.KEYCODE_BACK -> { KeyEvent.KEYCODE_BACK -> {
@@ -244,6 +252,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return super.onKeyDown(keyCode, event) 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 --- // --- lifecycle ---
override fun onStop() { override fun onStop() {

View File

@@ -1,12 +1,16 @@
package dev.castarr.tv.player package dev.castarr.tv.player
import android.content.Context import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import java.util.Locale
/** /**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause * 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 retryCount = 0
private var lastUrl: String = "" private var lastUrl: String = ""
/** One selectable audio track of the current stream. */
data class AudioTrack(val label: String, val selected: Boolean)
init { init {
player.addListener(object : Player.Listener { player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) = onChanged() override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged() override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onTracksChanged(tracks: Tracks) = onChanged()
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
// Self-heal: silently reconnect twice before surfacing (#13). // Self-heal: silently reconnect twice before surfacing (#13).
if (retryCount < 2 && lastUrl.isNotEmpty()) { if (retryCount < 2 && lastUrl.isNotEmpty()) {
@@ -105,6 +114,48 @@ class PlayerController(
onChanged() 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) { fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000) val target = (player.currentPosition + deltaSeconds * 1000)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import android.view.KeyEvent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -33,21 +33,23 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState 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 * Fullscreen playback with the minimal auto-hiding overlay. Key handling for
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only * play/pause/zapping/audio lives in MainActivity.onKeyDown; this composable
* grabs focus so list items underneath stop reacting. * only grabs focus so list items underneath stop reacting.
*/ */
@Composable @Composable
fun PlayerScreen(state: AppState) { fun PlayerScreen(state: AppState) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() } LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(state.playerVisible, state.playerState) { LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible) {
state.overlayVisible = true if (state.overlayVisible && state.playerState == "playing") {
if (state.playerState == "playing") { kotlinx.coroutines.delay(5000)
kotlinx.coroutines.delay(4000)
state.overlayVisible = false 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 @Composable
private fun Overlay(state: AppState) { private fun Overlay(state: AppState) {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(240.dp)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( 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), .padding(top = 22.dp, end = 30.dp),
verticalAlignment = Alignment.CenterVertically, 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)) Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
@@ -112,7 +117,7 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 36.dp) .padding(horizontal = 36.dp)
.padding(bottom = 30.dp), .padding(bottom = 26.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (state.isLive) { if (state.isLive) {
@@ -126,7 +131,7 @@ private fun Overlay(state: AppState) {
Text( Text(
"LIVE", "LIVE",
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 11.sp, fontSize = 11.sp,
letterSpacing = 2.sp, letterSpacing = 2.sp,
) )
@@ -140,7 +145,7 @@ private fun Overlay(state: AppState) {
else -> "Wird abgespielt" else -> "Wird abgespielt"
}, },
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
@@ -148,27 +153,110 @@ private fun Overlay(state: AppState) {
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
fontSize = 30.sp, fontSize = 30.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(16.dp))
val fraction = if (state.isLive || state.durationMs <= 0) 1f val info = state.currentChannel?.let { state.nowNext(it) }
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) 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( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(2.dp) .height(3.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line) .background(CastarrColors.line)
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth(fraction) .fillMaxWidth(fraction)
.height(2.dp) .height(3.dp)
.background(CastarrColors.accent) .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) { if (state.auth.isLoggedIn) {
Text( Text(
"Angemeldet als ${state.auth.username}", "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)) Spacer(Modifier.height(14.dp))
if (state.source.m3uUrl.isNotEmpty()) { if (state.source.m3uUrl.isNotEmpty()) {
@@ -74,7 +74,7 @@ fun SettingsScreen(state: AppState) {
} }
Spacer(Modifier.height(14.dp)) 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)) Spacer(Modifier.height(6.dp))
Row { Row {
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" } Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
@@ -90,7 +90,7 @@ fun SettingsScreen(state: AppState) {
} else { } else {
Text( Text(
"Nicht angemeldet.", "Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
ActionButton("Jetzt anmelden") { state.startOnboarding() } ActionButton("Jetzt anmelden") { state.startOnboarding() }
@@ -101,7 +101,7 @@ fun SettingsScreen(state: AppState) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""), "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)) Spacer(Modifier.height(8.dp))
Row { Row {
@@ -124,7 +124,7 @@ fun SettingsScreen(state: AppState) {
} }
if (updateStatus.isNotEmpty()) { if (updateStatus.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) 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)) Spacer(Modifier.height(10.dp))
Text( Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
) )
} else { } 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 @Composable
private fun SectionTitle(text: String) { 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 @Composable
@@ -170,7 +170,7 @@ fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
) { ) {
Text( Text(
label, label,
fontFamily = SpaceGrotesk, fontSize = 12.sp, fontFamily = AppFont, fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
) )
} }
@@ -190,7 +190,7 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
) { ) {
Text( Text(
label, label,
fontFamily = SpaceGrotesk, fontSize = 13.sp, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
) )
} }

View File

@@ -21,6 +21,15 @@ object CastarrColors {
val onAccent = Color(0xFF07110F) val onAccent = Color(0xFF07110F)
} }
/** Display face — only for the CASTARR wordmark. */
val SpaceGrotesk = FontFamily( val SpaceGrotesk = FontFamily(
Font(R.font.space_grotesk, FontWeight.Normal), 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)) { Column(Modifier.weight(1f)) {
Text( Text(
"Willkommen bei Castarr", "Willkommen bei Castarr",
color = CastarrColors.fg, fontFamily = SpaceGrotesk, color = CastarrColors.fg, fontFamily = AppFont,
fontSize = 32.sp, fontWeight = FontWeight.Medium, fontSize = 32.sp, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
@@ -90,11 +90,11 @@ fun WelcomeScreen(state: AppState) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
Pairing.remoteUrl(address), Pairing.remoteUrl(address),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
) )
} }
} else { } 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( Text(
if (done) "" else "$number", if (done) "" else "$number",
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint, 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)) Spacer(Modifier.width(14.dp))
Text( Text(
text, text,
color = if (active) CastarrColors.fg else CastarrColors.muted, color = if (active) CastarrColors.fg else CastarrColors.muted,
fontFamily = SpaceGrotesk, fontSize = 17.sp, fontFamily = AppFont, fontSize = 17.sp,
) )
} }
} }
@Composable @Composable
private fun Status(text: String) { 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.