17 Commits

Author SHA1 Message Date
be-nj
95f8793938 Bare, larger channel logos (no tile box)
Some checks failed
Build TV app / build (push) Failing after 2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:20:22 +02:00
be-nj
1757d8015d Move update source to Gitea (git.beckm4nn.net)
Some checks failed
Build TV app / build (push) Failing after 3s
Releases API for the version check; the APK itself is served from the
orphan 'apk' branch of the public repo (asset uploads need a token,
raw files do not). Transitional GitHub release carries this change so
existing installs hop over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:16:38 +02:00
be-nj
7c1f8d2c8b Channel rows with real logos (Entwurf A), no focus scale clipping
- Rows carry the Dispatcharr channel logo in a small tile (initials as
  fallback), sit on a surface instead of transparent black, and get the
  accent focus border; the group subline is gone — the logo carries
  channel identity.
- Focus scale disabled on settings rows, picker options, rail items and
  channel rows: the grown surface got clipped by its parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:12:07 +02:00
be-nj
3068eaea19 Picker with fullscreen scrim, framed QR tile
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:07:11 +02:00
be-nj
5b6cebd041 Settings: Wiedergabe card + picker dialogs instead of chip walls
- Quelle and Stream-Qualität move out of Konto into their own
  Wiedergabe card; Konto is account + Abmelden only.
- Choices open a TV-style picker dialog (focus starts on the current
  value, Back closes) with the current value + drawn caret shown at
  the row's trailing edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:01:38 +02:00
be-nj
5de71193ad fix: settings option chips (blob layout), real gear icon, overlay EPG filter, installer on main thread
- SegmentedRow lays label above wrapping option chips instead of
  squeezing them into the trailing slot (long profile names collapsed
  the label and blew the pill up into a blob).
- Gear icon: thick ring + stubby teeth instead of sun rays.
- Player overlay filters EPG placeholders and next==now duplicates
  like the channel list.
- Updater: startActivity from Dispatchers.Main — firing it from the IO
  dispatcher stalled the first install attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:55:47 +02:00
be-nj
deca5bd0cb fix(live): left lands on the selected group, right enters a fresh group at the top
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:50:36 +02:00
be-nj
a4391740ec Settings redesign: cards with focusable rows, segmented controls, gear button
- Settings page follows the Google TV pattern: Konto / App / Handy-
  Fernbedienung cards, each row a full-width focus target with label,
  subtitle and trailing value.
- Quelle and Stream-Qualität are segmented controls switched with
  left/right inside the focused row (click cycles).
- Account row with initial avatar, Abmelden as red row, update row
  with version badge; Update installieren only when one is available.
- Top bar: drawn gear icon button replaces the text pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:48:14 +02:00
be-nj
11a8056ce2 fix(player): opening key-up no longer clicks the freshly focused pause button
The key-down that opens the overlay is consumed by the activity, but its
key-up arrived after the play button grabbed focus and clicked it —
instant pause right after the overlay appeared. The overlay now swallows
select-releases whose press it never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:32:22 +02:00
be-nj
d98f277ec4 Channel numbers on the remote pad, EPG column redesign, calmer overlay
- Number keys type the stable channel number (position in Alle Sender,
  shown identically in every view) with a live feedback badge; commits
  after a short pause or as soon as no longer number can match.
- EPG cell is a left-aligned column with a fixed leading edge: title +
  time range, progress under the title, next line only when it differs;
  placeholder programmes (kein Programm / no information) count as no
  EPG data. Star hidden in the favorites view.
- Player overlay: no LIVE dot (reads as recording) and no status word
  while playing; state text only for paused/buffering/error.
- Settings pill gets a resting surface so it reads as a target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:27:55 +02:00
be-nj
8d6958536d Icon-only transport buttons (play/pause/stop need no labels)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:17:39 +02:00
be-nj
ee721708dc Drawn transport icons instead of glyphs (Android renders those as emoji)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:10:05 +02:00
be-nj
b04240ffde Live screen: group rail layout, exclusive filtering, cleaner top bar
- Groups live in a scrollable rail on the left (Alle Sender, Favoriten,
  then Dispatcharr groups with counts); focusing one opens it like a
  menu — filters no longer stack additively.
- Channel list sits right of the rail with Now/Next and progress;
  group subline only shown in the Alle view.
- Top bar: wordmark plus one context pill (Einstellungen / Zurück zu
  Live) instead of two text tabs; Back returns to Live instead of
  quitting the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:07:23 +02:00
be-nj
00762b6cbb Player overlay: real transport buttons (pause/stop/audio), OK opens controls
OK now opens the overlay like every TV player; its buttons are ordinary
focusable pills (play/pause, stop, audio-track cycle). Back closes the
overlay first, then stops. Auto-hide timer restarts on any interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:05:25 +02:00
be-nj
b12bfdf6e8 Key-hint row in player overlay instead of on-screen buttons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 23:59:11 +02:00
be-nj
0410eda9c9 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>
2026-08-25 23:54:02 +02:00
be-nj
e69c79061f Version 0.3.1 2026-08-25 23:42:16 +02:00
21 changed files with 1216 additions and 299 deletions

View File

@@ -38,7 +38,7 @@ resolved automatically (foojay) if the host only has a JRE.
```
APK output: `app/build/outputs/apk/<variant>/`. Releases on the
[releases page](https://github.com/be-nj/castarr/releases) are signed; install
[releases page](https://git.beckm4nn.net/benjamin/castarr/releases) are signed; install
over an existing debug build requires uninstalling first (signature change).
## Backend

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "0.3.0"
versionCode = 20
versionName = "0.8.4"
}
// Release signing from environment (see ~/.keys/castarr-release.env on the
@@ -81,6 +81,7 @@ dependencies {
implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
implementation("com.google.zxing:core:3.5.3")
implementation("io.coil-kt:coil-compose:2.7.0")
}
// Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -71,6 +71,14 @@
.btn-main:disabled { opacity: .35; box-shadow: none; }
.volrow { display: flex; align-items: center; gap: 16px; padding: 0 8px; }
.audiorow { display: none; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 8px 0; }
.audiorow.on { display: flex; }
.audiorow .lbl { color: #6b717a; font-size: 11px; letter-spacing: 2px; margin-right: 4px; }
.achip {
border: 1px solid #23262b; background: transparent; color: #9aa0a8;
border-radius: 999px; padding: 7px 14px; font-size: 13px; font-family: inherit;
}
.achip.sel { background: #5fd4c4; border-color: #5fd4c4; color: #07110f; font-weight: 600; }
input[type=range] { flex: 1; appearance: none; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: #1e2126; outline: none; }
input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
@@ -210,6 +218,7 @@
<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>
</div>
<div class="audiorow" id="audiorow"><span class="lbl">TON</span></div>
</section>
<!-- Sender -->
@@ -411,6 +420,19 @@
if (!state.volumeDragging && typeof s.volume === 'number') {
$('volume').value = Math.round(s.volume * 100);
}
const tracks = s.audioTracks || [];
const row = $('audiorow');
row.classList.toggle('on', hasMedia && tracks.length > 1);
while (row.children.length > 1) row.removeChild(row.lastChild);
if (tracks.length > 1) {
tracks.forEach((label, i) => {
const chip = document.createElement('button');
chip.className = 'achip' + (i === s.audioSelected ? ' sel' : '');
chip.textContent = label;
chip.addEventListener('click', () => send({ type: 'set_audio', index: i }));
row.appendChild(chip);
});
}
}
function renderChannels() {

View File

@@ -38,6 +38,22 @@ 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
/** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("")
/** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */
var overlayPing by mutableLongStateOf(0L)
private set
fun pingOverlay() {
overlayVisible = true
overlayPing++
}
/** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
@@ -158,13 +174,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()
}
}

View File

@@ -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()
@@ -194,10 +198,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// --- TV remote keys during playback (TV-PC / TV-PP) ---
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// Number pad: type the channel number shown in the list (position in
// "Alle Sender"), switch after a short pause or as soon as no longer
// number can match. Ignored on screens with text fields.
if (keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 &&
state.screen != AppState.Screen.WELCOME && state.screen != AppState.Screen.ADVANCED
) {
onDigit(keyCode - KeyEvent.KEYCODE_0)
return true
}
if (state.playerVisible) {
when (keyCode) {
// OK opens the control overlay (buttons take over from
// there via normal Compose focus); with the overlay open
// this key is consumed by the focused button and never
// reaches the activity.
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_ENTER -> {
state.pingOverlay()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
state.player.toggle()
return true
@@ -224,18 +244,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1)
return true
}
// With the overlay open, left/right moves button focus and
// never reaches the activity. Hidden overlay: seek on
// seekable media, otherwise just bring the controls up.
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS)
if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else state.pingOverlay()
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 state.pingOverlay()
return true
}
KeyEvent.KEYCODE_BACK -> {
state.stopPlayback()
if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true
}
else -> Unit
@@ -244,6 +270,40 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return super.onKeyDown(keyCode, event)
}
private val digitCommit = Runnable { commitDigits() }
private fun onDigit(digit: Int) {
if (state.digitBuffer.length >= 4) return
state.digitBuffer += digit.toString()
mainHandler.removeCallbacks(digitCommit)
val number = state.digitBuffer.toIntOrNull() ?: return commitDigits()
// Another digit could still form a valid number? Then wait for it.
if (number * 10 > state.activeChannels().size) commitDigits()
else mainHandler.postDelayed(digitCommit, DIGIT_COMMIT_MS)
}
private fun commitDigits() {
mainHandler.removeCallbacks(digitCommit)
val number = state.digitBuffer.toIntOrNull()
state.digitBuffer = ""
val channel = number?.let { state.activeChannels().getOrNull(it - 1) } ?: return
state.play(channel)
}
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() {
@@ -262,5 +322,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
private companion object {
const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L
const val DIGIT_COMMIT_MS = 1_800L
}
}

View File

@@ -126,7 +126,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
url = streamUrl(uuid),
group = groups[obj.optInt("channel_group_id")].orEmpty(),
logo = "",
// The list serializer only carries logo ids; the
// cache endpoint serves the image without auth.
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
?: obj.optInt("logo_id").takeIf { it > 0 })
?.let { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" }
.orEmpty(),
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
backendId = id,
streamKey = uuid,

View File

@@ -9,6 +9,12 @@ import java.util.Locale
data class Programme(val start: Long, val stop: Long, val title: String)
/** Provider EPGs pad gaps with placeholder programmes — treat as no data. */
fun isEpgPlaceholder(title: String): Boolean {
val t = title.lowercase()
return "kein programm" in t || "keine information" in t || "no information" in t
}
data class NowNext(val now: Programme?, val next: Programme?)
/**

View File

@@ -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)

View File

@@ -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")) }

View File

@@ -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)
}
}
}

View File

@@ -10,11 +10,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -22,11 +28,23 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.Surface
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun CastarrApp(state: AppState) {
// Back always leads home instead of quitting the app by accident.
androidx.activity.compose.BackHandler(
enabled = !state.playerVisible && state.screen != AppState.Screen.LIVE
&& state.screen != AppState.Screen.WELCOME
) {
state.screen = if (state.screen == AppState.Screen.ADVANCED) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -48,6 +66,41 @@ fun CastarrApp(state: AppState) {
if (state.playerVisible) {
PlayerScreen(state)
}
if (state.digitBuffer.isNotEmpty()) {
DigitBadge(state)
}
}
}
/** Big feedback badge while typing a channel number on the remote. */
@Composable
private fun DigitBadge(state: AppState) {
val target = state.digitBuffer.toIntOrNull()
?.let { state.activeChannels().getOrNull(it - 1) }
Column(
modifier = Modifier
.padding(top = 60.dp, end = 48.dp)
.fillMaxSize()
.wrapContentSize(Alignment.TopEnd)
.clip(RoundedCornerShape(14.dp))
.background(CastarrColors.surface)
.padding(horizontal = 22.dp, vertical = 14.dp),
horizontalAlignment = Alignment.End,
) {
Text(
state.digitBuffer,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 34.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 2.sp,
)
Text(
target?.name ?: "kein Sender",
color = if (target != null) CastarrColors.muted else CastarrColors.faint,
fontFamily = AppFont,
fontSize = 13.sp,
)
}
}
@@ -65,14 +118,6 @@ private fun TopBar(state: AppState) {
fontWeight = FontWeight.Medium,
letterSpacing = 4.sp,
)
Spacer(Modifier.width(36.dp))
TabItem("Live", state.screen == AppState.Screen.LIVE) {
state.screen = AppState.Screen.LIVE
}
Spacer(Modifier.width(10.dp))
TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) {
state.screen = AppState.Screen.SETTINGS
}
Spacer(Modifier.weight(1f))
state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -83,30 +128,59 @@ 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)
}
Spacer(Modifier.width(18.dp))
}
GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
}
}
}
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) {
private fun GearButton(onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
modifier = Modifier.semantics { contentDescription = "Einstellungen" },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
containerColor = CastarrColors.surface,
contentColor = CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = SpaceGrotesk,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
val color = LocalContentColor.current
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.size(18.dp)) {
val center = Offset(size.width / 2f, size.height / 2f)
// Thick ring with a hole plus stubby teeth — thin lines
// read as a sun, not a gear.
val ringWidth = size.minDimension * 0.20f
drawCircle(
color,
radius = size.minDimension * 0.27f,
center = center,
style = Stroke(ringWidth),
)
val toothStart = size.minDimension * 0.34f
val toothEnd = size.minDimension * 0.50f
for (i in 0 until 8) {
val angle = (i + 0.5) * (PI / 4)
val dx = cos(angle).toFloat()
val dy = sin(angle).toFloat()
drawLine(
color,
start = center + Offset(dx * toothStart, dy * toothStart),
end = center + Offset(dx * toothEnd, dy * toothEnd),
strokeWidth = size.minDimension * 0.17f,
cap = StrokeCap.Butt,
)
}
}
}
}
}

View File

@@ -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),
)
}

View File

@@ -1,11 +1,13 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -13,29 +15,46 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.SubcomposeAsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Live tab: group rail on the left (focusing a group opens it — exclusive,
* like a menu), channels of the open group with Now/Next on the right.
*/
@Composable
fun LiveScreen(state: AppState) {
val genericChannels by state.source.channels.collectAsStateWithLifecycle()
@@ -50,61 +69,137 @@ fun LiveScreen(state: AppState) {
val groups = remember(allChannels) {
allChannels.mapNotNull { it.group.ifEmpty { null } }.distinct().sorted()
}
val channels = allChannels
.let { list -> if (isDispatcharr && state.favoritesOnly) list.filter { it.backendId in favorites } else list }
.let { list -> state.groupFilter?.let { g -> list.filter { it.group == g } } ?: list }
val channels = when {
state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
if (allChannels.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"Noch keine Sender — richte unter Einstellungen eine Quelle ein.",
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 15.sp,
)
}
return
}
Column(Modifier.fillMaxSize()) {
LazyRow(
modifier = Modifier.padding(bottom = 6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
// D-pad choreography: left from any channel row lands on the SELECTED
// group (not the spatially nearest one), right from the rail enters the
// list at the top — which also gets scrolled up on every group change.
val railFocus = remember { FocusRequester() }
val listFocus = remember { FocusRequester() }
val listState = rememberLazyListState()
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
listState.scrollToItem(0)
}
val intoList = if (channels.isEmpty()) Modifier
else Modifier.focusProperties { right = listFocus }
Row(Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.width(264.dp)
.fillMaxHeight(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 28.dp, end = 12.dp, top = 4.dp, bottom = 32.dp
),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
FilterChip("Alle", !state.favoritesOnly && state.groupFilter == null) {
val selected = !state.favoritesOnly && state.groupFilter == null
GroupItem(
label = "Alle Sender",
count = allChannels.size,
selected = selected,
modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier
),
) {
state.favoritesOnly = false
state.groupFilter = null
}
}
if (isDispatcharr) {
item {
FilterChip("★ Favoriten", state.favoritesOnly) {
state.favoritesOnly = !state.favoritesOnly
GroupItem(
label = "★ Favoriten",
count = favorites.size,
selected = state.favoritesOnly,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
),
) {
state.favoritesOnly = true
state.groupFilter = null
}
}
}
item {
Box(
Modifier
.padding(horizontal = 14.dp, vertical = 8.dp)
.fillMaxWidth()
.height(1.dp)
.background(CastarrColors.line)
)
}
items(groups, key = { it }) { group ->
FilterChip(group, state.groupFilter == group) {
state.groupFilter = if (state.groupFilter == group) null else group
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 40.dp, end = 40.dp, top = 8.dp, bottom = 32.dp
GroupItem(
label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group,
modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(channels, key = { it.url + it.name }) { channel ->
state.favoritesOnly = false
state.groupFilter = group
}
}
}
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (channels.isEmpty()) {
item {
Text(
if (state.favoritesOnly)
"Noch keine Favoriten — halte OK auf einem Sender gedrückt."
else "Diese Gruppe ist leer.",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 14.sp,
modifier = Modifier.padding(24.dp),
)
}
}
itemsIndexed(channels, key = { _, c -> c.url + c.name }) { listIndex, channel ->
ChannelRow(
channel = channel,
index = channels.indexOf(channel),
// Stable channel number = position in "Alle Sender",
// matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1,
modifier = Modifier
.focusProperties { left = railFocus }
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier),
nowNext = state.nowNext(channel),
playing = state.currentChannel?.url == channel.url,
favorite = isDispatcharr && channel.backendId in favorites,
// All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
favorite = isDispatcharr && !state.favoritesOnly &&
channel.backendId in favorites,
epgStamp = epgStamp,
onLongClick = if (isDispatcharr) {
{ state.dispatcharr.toggleFavorite(channel) }
@@ -116,30 +211,57 @@ fun LiveScreen(state: AppState) {
}
@Composable
private fun FilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
private fun GroupItem(
label: String,
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
// Focusing a group opens it right away (browse like a menu);
// clicking is not required, but harmless.
onClick = onSelect,
modifier = modifier
.fillMaxWidth()
.onFocusChanged { if (it.isFocused) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
fontFamily = SpaceGrotesk,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
"$count",
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
@Composable
private fun ChannelRow(
channel: Channel,
index: Int,
number: Int,
modifier: Modifier = Modifier,
nowNext: NowNext,
playing: Boolean,
favorite: Boolean,
@@ -150,57 +272,58 @@ private fun ChannelRow(
Surface(
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
// No focus scale — grown rows get clipped at the pane edges.
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent,
containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(12.dp),
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${index + 1}",
"$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(44.dp),
modifier = Modifier.width(40.dp),
)
if (favorite) {
Text(
"",
color = CastarrColors.accent,
fontSize = 13.sp,
modifier = Modifier.width(20.dp),
)
}
Column(Modifier.weight(1f)) {
LogoTile(channel)
Spacer(Modifier.width(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.width(230.dp),
) {
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,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (channel.group.isNotEmpty()) {
Text(
channel.group,
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (favorite) {
Spacer(Modifier.width(8.dp))
Text("", color = CastarrColors.accent, fontSize = 13.sp)
}
}
NowNextCell(nowNext)
Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f))
if (playing) {
Spacer(Modifier.width(12.dp))
Box(
@@ -214,25 +337,76 @@ private fun ChannelRow(
}
}
/** Bare channel logo (they ship transparent); initials as fallback. */
@Composable
private fun NowNextCell(nowNext: NowNext) {
val now = nowNext.now
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) {
private fun LogoTile(channel: Channel) {
val initials = channel.name.split(" ").filter { it.isNotBlank() }
.take(2).map { it.first() }.joinToString("").uppercase()
Box(Modifier.size(width = 52.dp, height = 40.dp), contentAlignment = Alignment.Center) {
if (channel.logo.isNotEmpty()) {
SubcomposeAsyncImage(
model = channel.logo,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
loading = { LogoInitials(initials) },
error = { LogoInitials(initials) },
)
} else {
LogoInitials(initials)
}
}
}
@Composable
private fun LogoInitials(initials: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
initials,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
/**
* Left-aligned EPG column with a fixed leading edge: title + times, progress
* under the title, next programme only when it differs (design review P1/P2).
*/
@Composable
private fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
Column(modifier) {
if (now != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.title,
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontSize = 13.sp,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
Text(
"${formatClock(now.start)}${formatClock(now.stop)}",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(5.dp))
Spacer(Modifier.height(6.dp))
Box(
Modifier
.width(180.dp)
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line)
@@ -244,13 +418,16 @@ private fun NowNextCell(nowNext: NowNext) {
.background(CastarrColors.accent)
)
}
nowNext.next?.let { next ->
Spacer(Modifier.height(4.dp))
val next = nowNext.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text(
"danach: ${next.title}",
color = CastarrColors.faint,
fontFamily = SpaceGrotesk,
fontSize = 11.sp,
fontFamily = AppFont,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

View File

@@ -1,6 +1,6 @@
package dev.castarr.tv.ui
import android.view.KeyEvent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
@@ -17,7 +17,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -25,29 +28,48 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import dev.castarr.tv.data.isEpgPlaceholder
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.
* Fullscreen playback. OK opens the auto-hiding overlay whose transport
* buttons are ordinary focusable controls; media/channel keys are handled
* in MainActivity.onKeyDown.
*/
@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.overlayVisible) {
// While the overlay is hidden the root grabs focus so list items
// underneath stop reacting; the overlay's play button takes over
// when it appears.
if (!state.overlayVisible) focusRequester.requestFocus()
}
LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible, state.overlayPing) {
if (state.overlayVisible && state.playerState == "playing") {
kotlinx.coroutines.delay(5000)
state.overlayVisible = false
}
}
@@ -76,17 +98,44 @@ 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()) {
val playFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { playFocus.requestFocus() }
// The key-UP of the very press that opened this overlay arrives after
// the play button grabbed focus and would click it (instant pause).
// Swallow select-releases whose press the overlay never saw.
var sawSelectDown by remember { mutableStateOf(false) }
Box(
Modifier
.fillMaxSize()
// Any key while the overlay is open keeps it open a while longer.
.onPreviewKeyEvent { event ->
state.pingOverlay()
val select = event.key == Key.DirectionCenter ||
event.key == Key.Enter || event.key == Key.NumPadEnter
when {
select && event.type == KeyEventType.KeyDown -> {
sawSelectDown = true
false
}
select && event.type == KeyEventType.KeyUp && !sawSelectDown -> true
else -> false
}
},
) {
Box(
Modifier
.fillMaxWidth()
.height(200.dp)
.height(280.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 +146,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,63 +161,194 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(horizontal = 36.dp)
.padding(bottom = 30.dp),
.padding(bottom = 24.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.isLive) {
Box(
Modifier
.size(6.dp)
.clip(CircleShape)
.background(CastarrColors.live)
)
Spacer(Modifier.width(8.dp))
Text(
"LIVE",
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontSize = 11.sp,
letterSpacing = 2.sp,
)
}
Spacer(Modifier.weight(1f))
Text(
when (state.playerState) {
// No status word while simply playing, and no LIVE badge — a
// red dot reads as "recording" (user feedback).
val statusWord = when (state.playerState) {
"paused" -> "Pausiert"
"buffering" -> "Lädt…"
"error" -> "Wiedergabefehler"
else -> "Wird abgespielt"
},
else -> null
}
if (statusWord != null) {
Row {
Spacer(Modifier.weight(1f))
Text(
statusWord,
color = CastarrColors.muted,
fontFamily = SpaceGrotesk,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(4.dp))
}
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?.takeUnless { isEpgPlaceholder(it.title) }
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?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now?.title
}?.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)
)
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus,
) { state.player.toggle() }
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() }
if (state.audioTracks.size > 1) {
Spacer(Modifier.width(10.dp))
val current = state.audioTracks.firstOrNull { it.selected }
PillButton(label = "Ton: ${current?.label ?: ""}") { state.cycleAudio(1) }
}
Spacer(Modifier.weight(1f))
Text(
"▲ ▼ Sender wechseln",
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
)
}
}
}
}
private enum class PillIcon { PLAY, PAUSE, STOP }
/** Round icon-only transport button; the icons are drawn, not typed —
* glyphs like U+23F8 render as emoji on Android. */
@Composable
private fun IconButton(
icon: PillIcon,
contentDescription: String,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = (if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.semantics { this.contentDescription = contentDescription },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
val color = LocalContentColor.current
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.size(15.dp)) {
when (icon) {
PillIcon.PLAY -> drawPath(
Path().apply {
moveTo(0f, 0f)
lineTo(size.width, size.height / 2f)
lineTo(0f, size.height)
close()
},
color,
)
PillIcon.PAUSE -> {
val bar = size.width * 0.3f
drawRect(color, size = androidx.compose.ui.geometry.Size(bar, size.height))
drawRect(
color,
topLeft = androidx.compose.ui.geometry.Offset(size.width - bar, 0f),
size = androidx.compose.ui.geometry.Size(bar, size.height),
)
}
PillIcon.STOP -> drawRect(color)
}
}
}
}
}
@Composable
private fun PillButton(
label: String,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
)
}
}

View File

@@ -1,19 +1,26 @@
package dev.castarr.tv.ui
import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -23,10 +30,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
@@ -37,9 +52,18 @@ import dev.castarr.tv.pairing.Qr
import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch
/** One open picker dialog: which setting, its options, what happens on pick. */
private data class Picker(
val title: String,
val options: List<String>,
val selected: Int,
val onPick: (Int) -> Unit,
)
/**
* Ten-Foot-Regel (#14): one screen, focusable buttons/chips only — no text
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen.
* Google-TV-style settings: cards with full-width focusable rows; choices
* open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung).
* Ten-Foot-Regel (#14): no text fields, no scrolling.
*/
@Composable
fun SettingsScreen(state: AppState) {
@@ -47,133 +71,385 @@ fun SettingsScreen(state: AppState) {
val scope = rememberCoroutineScope()
val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) }
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp)
.padding(horizontal = 40.dp, vertical = 12.dp)
) {
Column(Modifier.weight(1.2f)) {
SectionTitle("Konto")
Spacer(Modifier.height(8.dp))
Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsCard("Konto") {
if (state.auth.isLoggedIn) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(34.dp)
.clip(CircleShape)
.background(CastarrColors.accentDim),
contentAlignment = Alignment.Center,
) {
Text(
"Angemeldet als ${state.auth.username}",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
state.auth.username.take(1).uppercase().ifEmpty { "?" },
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(14.dp))
if (state.source.m3uUrl.isNotEmpty()) {
Row {
Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) {
state.setMode(AppState.SourceMode.DISPATCHARR)
}
Spacer(Modifier.width(8.dp))
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) {
state.setMode(AppState.SourceMode.GENERIC)
Spacer(Modifier.width(14.dp))
Column {
Text(
state.auth.username,
color = CastarrColors.fg, fontFamily = AppFont, fontSize = 15.sp,
)
Text(
"Angemeldet über SSO",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 11.sp,
)
}
}
Spacer(Modifier.height(14.dp))
}
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
Row {
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
Spacer(Modifier.width(8.dp))
Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" }
profiles.forEach { name ->
Spacer(Modifier.width(8.dp))
Chip(name, state.outputProfile == name) { state.outputProfile = name }
}
}
Spacer(Modifier.height(18.dp))
ActionButton("Abmelden", danger = true) { state.startOnboarding() }
SettingRow("Abmelden", danger = true) { state.startOnboarding() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
Spacer(Modifier.height(12.dp))
ActionButton("Jetzt anmelden") { state.startOnboarding() }
SettingRow("Jetzt anmelden") { state.startOnboarding() }
}
}
Spacer(Modifier.height(28.dp))
SectionTitle("App")
Spacer(Modifier.height(8.dp))
Text(
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
SettingsCard("Wiedergabe") {
if (state.auth.isLoggedIn && state.source.m3uUrl.isNotEmpty()) {
val modes = listOf("Dispatcharr", "Eigene M3U")
val modeIndex = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1
SettingRow(
"Quelle",
subtitle = "Woher die Senderliste kommt",
trailing = { ValueWithCaret(modes[modeIndex]) },
) {
picker = Picker("Quelle", modes, modeIndex) { index ->
state.setMode(
if (index == 0) AppState.SourceMode.DISPATCHARR
else AppState.SourceMode.GENERIC
)
Spacer(Modifier.height(8.dp))
Row {
ActionButton(
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen"
}
}
}
val qualities = listOf("Standard", "Original") + profiles
val qualityIndex = when (state.outputProfile) {
"" -> 0
"raw" -> 1
else -> (profiles.indexOf(state.outputProfile) + 2).coerceAtLeast(0)
}
SettingRow(
"Stream-Qualität",
subtitle = "Standard remuxt nur den Ton",
trailing = { ValueWithCaret(qualities[qualityIndex]) },
) {
picker = Picker("Stream-Qualität", qualities, qualityIndex) { index ->
state.outputProfile = when (index) {
0 -> ""
1 -> "raw"
else -> profiles.getOrElse(index - 2) { "" }
}
}
}
}
SettingsCard("App") {
SettingRow(
"Version ${BuildConfig.VERSION_NAME}",
subtitle = updateStatus.ifEmpty { "Klicken prüft auf Updates" },
trailing = state.updateAvailable?.let { version ->
{
Box(
Modifier
.clip(RoundedCornerShape(999.dp))
.background(CastarrColors.accentDim)
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
"$version verfügbar",
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
},
) {
scope.launch {
updateStatus = if (state.updateAvailable != null) {
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
} else {
when (val v = UpdateChecker.check(state)) {
updateStatus = when (val v = UpdateChecker.check(state)) {
null -> "App ist aktuell"
else -> "$v verfügbar — nochmal drücken zum Installieren"
else -> "$v verfügbar"
}
}
}
if (state.updateAvailable != null) {
SettingRow("Update installieren") {
scope.launch {
updateStatus = "Update wird heruntergeladen…"
updateStatus =
UpdateChecker.downloadAndInstall(context, state)
?: "Installation wird geöffnet…"
}
Spacer(Modifier.width(8.dp))
ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED }
}
if (updateStatus.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(updateStatus, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp)
}
SettingRow(
"Erweitert",
subtitle = "M3U/EPG-Adressen von Hand eintragen",
) { state.screen = AppState.Screen.ADVANCED }
}
}
Spacer(Modifier.width(40.dp))
Spacer(Modifier.width(16.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
SectionTitle("Handy-Fernbedienung")
Spacer(Modifier.height(10.dp))
Column(Modifier.weight(0.75f)) {
SettingsCard("Handy-Fernbedienung") {
val address = remember { Pairing.lanAddress() }
if (address != null) {
val qr = remember(address) {
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216"))
Qr.encode(
Pairing.pairingUrl(context, address), 400,
android.graphics.Color.parseColor("#101216"),
)
}
Column(
Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
QrCard(qr)
Spacer(Modifier.height(10.dp))
Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 12.sp,
"Mit der Handy-Kamera scannen",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
}
@Composable
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = SpaceGrotesk, fontSize = 12.sp,
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp,
)
}
} else {
Text(
"Keine Netzwerkverbindung",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
}
}
}
picker?.let { current ->
PickerDialog(current) { picker = null }
}
}
/** TV-friendly dropdown replacement: fullscreen scrim, options centered. */
@Composable
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
val selectedFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier
.fillMaxSize()
// Own heavy scrim — the platform default is too light and
// lets the white QR card fight the panel.
.background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.width(360.dp)
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
picker.title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
)
picker.options.forEachIndexed { index, option ->
val selected = index == picker.selected
Surface(
onClick = {
picker.onPick(index)
onClose()
},
modifier = Modifier
.fillMaxWidth()
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
option,
color = if (selected) CastarrColors.accent else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (selected) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(CastarrColors.accent)
)
}
}
}
}
}
}
}
}
/** Current value plus a small drawn caret, shown at a row's trailing edge. */
@Composable
private fun ValueWithCaret(value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
value,
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
val color = CastarrColors.faint
Canvas(Modifier.size(9.dp)) {
drawPath(
Path().apply {
moveTo(0f, size.height * 0.3f)
lineTo(size.width, size.height * 0.3f)
lineTo(size.width / 2f, size.height * 0.75f)
close()
},
color,
)
}
}
}
@Composable
private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(CastarrColors.surface)
.padding(horizontal = 10.dp, vertical = 14.dp)
) {
Text(
title.uppercase(),
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
)
content()
}
}
private val rowShape = RoundedCornerShape(10.dp)
/** One full-width focusable settings row: label + subtitle, value right. */
@Composable
private fun SettingRow(
label: String,
subtitle: String? = null,
danger: Boolean = false,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg,
),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, if (danger) CastarrColors.live else CastarrColors.accent),
shape = rowShape,
),
),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
label,
color = if (danger) CastarrColors.live else CastarrColors.fg,
fontFamily = AppFont,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
subtitle,
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (trailing != null) {
Spacer(Modifier.width(12.dp))
trailing()
}
}
}
}
@Composable
@@ -190,7 +466,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),
)
}
@@ -198,17 +474,27 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
@Composable
fun QrCard(bitmap: Bitmap) {
// Quiet zone stays white for scannability, but the tile sits in a
// bordered frame on the card instead of floating as a bare white block.
Column(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD))
.padding(14.dp),
.clip(RoundedCornerShape(18.dp))
.background(CastarrColors.bg)
.border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
.padding(8.dp),
verticalArrangement = Arrangement.Center,
) {
Column(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
.padding(10.dp),
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(170.dp),
modifier = Modifier.size(164.dp),
)
}
}
}

View File

@@ -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),
)

View File

@@ -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)
}

View File

@@ -13,11 +13,17 @@ import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/** In-app updater over GitHub releases (#12). Explicit, never automatic. */
/** In-app updater over Gitea releases (#12). Explicit, never automatic. */
object UpdateChecker {
private const val TAG = "UpdateChecker"
private const val LATEST = "https://api.github.com/repos/be-nj/castarr/releases/latest"
private const val LATEST =
"https://git.beckm4nn.net/api/v1/repos/benjamin/castarr/releases/latest"
// Release assets need an API token to upload, so the APK is served from
// an orphan branch instead — anonymously fetchable on a public repo.
private const val APK_FALLBACK =
"https://git.beckm4nn.net/benjamin/castarr/raw/branch/apk/castarr.apk"
private var apkUrl: String = ""
@@ -35,6 +41,7 @@ object UpdateChecker {
break
}
}
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url
val version = "v$tag"
@@ -69,7 +76,9 @@ object UpdateChecker {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
// Launching from the IO dispatcher stalled the installer on
// the first attempt — activities start from the main thread.
withContext(Dispatchers.Main) { context.startActivity(intent) }
null
}.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}")

Binary file not shown.

Binary file not shown.

Binary file not shown.