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:
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")) }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
app/src/main/res/font/inter_medium.ttf
Normal file
BIN
app/src/main/res/font/inter_medium.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_regular.ttf
Normal file
BIN
app/src/main/res/font/inter_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_semibold.ttf
Normal file
BIN
app/src/main/res/font/inter_semibold.ttf
Normal file
Binary file not shown.
Reference in New Issue
Block a user