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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user