10 Commits

Author SHA1 Message Date
17ff402de0 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
6ee7f53e3f 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
e2e841d49f Picker with fullscreen scrim, framed QR tile
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 01:07:11 +02:00
4aa5a85562 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
ac3612ac65 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
c394d8e946 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
d7da1b2ff4 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
22bda1644f 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
b59c3deee4 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
b13caf41c2 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
11 changed files with 799 additions and 251 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 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). over an existing debug build requires uninstalling first (signature change).
## Backend ## Backend

View File

@@ -12,8 +12,8 @@ android {
applicationId = "dev.castarr.tv" applicationId = "dev.castarr.tv"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 10 versionCode = 19
versionName = "0.5.0" versionName = "0.8.3"
} }
// Release signing from environment (see ~/.keys/castarr-release.env on the // 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:2.3.1")
implementation("org.nanohttpd:nanohttpd-websocket:2.3.1") implementation("org.nanohttpd:nanohttpd-websocket:2.3.1")
implementation("com.google.zxing:core:3.5.3") 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 // Full JDK for javac via toolchain (host may only have a JRE); resolved by

View File

@@ -42,6 +42,9 @@ class AppState(
private set private set
private var lastNowTitle: String? = null 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 /** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */ * auto-hide timer. */
var overlayPing by mutableLongStateOf(0L) var overlayPing by mutableLongStateOf(0L)

View File

@@ -198,6 +198,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// --- TV remote keys during playback (TV-PC / TV-PP) --- // --- TV remote keys during playback (TV-PC / TV-PP) ---
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { 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) { if (state.playerVisible) {
when (keyCode) { when (keyCode) {
// OK opens the control overlay (buttons take over from // OK opens the control overlay (buttons take over from
@@ -261,6 +270,26 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return super.onKeyDown(keyCode, event) 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) { private fun cycleAudioWithOverlay(direction: Int) {
state.overlayVisible = true state.overlayVisible = true
state.cycleAudio(direction) state.cycleAudio(direction)
@@ -293,5 +322,6 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
private companion object { private companion object {
const val TICK_INTERVAL_MS = 2_000L const val TICK_INTERVAL_MS = 2_000L
const val SEEK_STEP_SECONDS = 10L 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") }, name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
url = streamUrl(uuid), url = streamUrl(uuid),
group = groups[obj.optInt("channel_group_id")].orEmpty(), 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") }, tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
backendId = id, backendId = id,
streamKey = uuid, streamKey = uuid,

View File

@@ -9,6 +9,12 @@ import java.util.Locale
data class Programme(val start: Long, val stop: Long, val title: String) 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?) data class NowNext(val now: Programme?, val next: Programme?)
/** /**

View File

@@ -10,11 +10,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -22,8 +28,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable @Composable
fun CastarrApp(state: AppState) { fun CastarrApp(state: AppState) {
@@ -56,6 +66,41 @@ fun CastarrApp(state: AppState) {
if (state.playerVisible) { if (state.playerVisible) {
PlayerScreen(state) 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,
)
} }
} }
@@ -87,34 +132,55 @@ private fun TopBar(state: AppState) {
} }
Spacer(Modifier.width(18.dp)) Spacer(Modifier.width(18.dp))
} }
TabItem( GearButton {
if (state.screen == AppState.Screen.LIVE) "Einstellungen" else "Zurück zu Live",
selected = false,
) {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE else AppState.Screen.LIVE
} }
} }
} }
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable @Composable
private fun TabItem(label: String, selected: Boolean, onClick: () -> Unit) { private fun GearButton(onClick: () -> Unit) {
Surface( Surface(
onClick = onClick, onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)), modifier = Modifier.semantics { contentDescription = "Einstellungen" },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent, containerColor = CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, contentColor = CastarrColors.muted,
focusedContainerColor = CastarrColors.accent, focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,
), ),
) { ) {
Text( val color = LocalContentColor.current
label, Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
fontFamily = AppFont, Canvas(Modifier.size(18.dp)) {
fontSize = 14.sp, val center = Offset(size.width / 2f, size.height / 2f)
fontWeight = FontWeight.Medium, // Thick ring with a hole plus stubby teeth — thin lines
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp), // 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

@@ -1,5 +1,6 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -14,15 +15,21 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -30,12 +37,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ClickableSurfaceDefaults
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import dev.castarr.tv.data.NowNext import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.playlist.Channel 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, * Live tab: group rail on the left (focusing a group opens it — exclusive,
@@ -73,6 +87,18 @@ fun LiveScreen(state: AppState) {
return return
} }
// 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()) { Row(Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -84,10 +110,14 @@ fun LiveScreen(state: AppState) {
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
item { item {
val selected = !state.favoritesOnly && state.groupFilter == null
GroupItem( GroupItem(
label = "Alle Sender", label = "Alle Sender",
count = allChannels.size, count = allChannels.size,
selected = !state.favoritesOnly && state.groupFilter == null, selected = selected,
modifier = intoList.then(
if (selected) Modifier.focusRequester(railFocus) else Modifier
),
) { ) {
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = null state.groupFilter = null
@@ -99,6 +129,9 @@ fun LiveScreen(state: AppState) {
label = "★ Favoriten", label = "★ Favoriten",
count = favorites.size, count = favorites.size,
selected = state.favoritesOnly, selected = state.favoritesOnly,
modifier = intoList.then(
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
),
) { ) {
state.favoritesOnly = true state.favoritesOnly = true
state.groupFilter = null state.groupFilter = null
@@ -119,6 +152,9 @@ fun LiveScreen(state: AppState) {
label = group, label = group,
count = remember(allChannels, group) { allChannels.count { it.group == group } }, count = remember(allChannels, group) { allChannels.count { it.group == group } },
selected = state.groupFilter == group, selected = state.groupFilter == group,
modifier = intoList.then(
if (state.groupFilter == group) Modifier.focusRequester(railFocus) else Modifier
),
) { ) {
state.favoritesOnly = false state.favoritesOnly = false
state.groupFilter = group state.groupFilter = group
@@ -127,13 +163,14 @@ fun LiveScreen(state: AppState) {
} }
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight(), .fillMaxHeight(),
contentPadding = androidx.compose.foundation.layout.PaddingValues( contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp start = 8.dp, end = 40.dp, top = 4.dp, bottom = 32.dp
), ),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
if (channels.isEmpty()) { if (channels.isEmpty()) {
item { item {
@@ -148,14 +185,21 @@ fun LiveScreen(state: AppState) {
) )
} }
} }
items(channels, key = { it.url + it.name }) { channel -> itemsIndexed(channels, key = { _, c -> c.url + c.name }) { listIndex, channel ->
ChannelRow( ChannelRow(
channel = channel, channel = channel,
index = channels.indexOf(channel), // Stable channel number = position in "Alle Sender",
showGroup = state.groupFilter == null, // 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), nowNext = state.nowNext(channel),
playing = state.currentChannel?.url == channel.url, 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, epgStamp = epgStamp,
onLongClick = if (isDispatcharr) { onLongClick = if (isDispatcharr) {
{ state.dispatcharr.toggleFavorite(channel) } { state.dispatcharr.toggleFavorite(channel) }
@@ -167,15 +211,22 @@ fun LiveScreen(state: AppState) {
} }
@Composable @Composable
private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: () -> Unit) { private fun GroupItem(
label: String,
count: Int,
selected: Boolean,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface( Surface(
// Focusing a group opens it right away (browse like a menu); // Focusing a group opens it right away (browse like a menu);
// clicking is not required, but harmless. // clicking is not required, but harmless.
onClick = onSelect, onClick = onSelect,
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { if (it.isFocused) onSelect() }, .onFocusChanged { if (it.isFocused) onSelect() },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else Color.Transparent, containerColor = if (selected) CastarrColors.accentDim else Color.Transparent,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted, contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
@@ -209,8 +260,8 @@ private fun GroupItem(label: String, count: Int, selected: Boolean, onSelect: ()
@Composable @Composable
private fun ChannelRow( private fun ChannelRow(
channel: Channel, channel: Channel,
index: Int, number: Int,
showGroup: Boolean, modifier: Modifier = Modifier,
nowNext: NowNext, nowNext: NowNext,
playing: Boolean, playing: Boolean,
favorite: Boolean, favorite: Boolean,
@@ -221,36 +272,41 @@ private fun ChannelRow(
Surface( Surface(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)), 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( colors = ClickableSurfaceDefaults.colors(
containerColor = if (playing) CastarrColors.accentDim else Color.Transparent, containerColor = if (playing) CastarrColors.accentDim else CastarrColors.surface,
contentColor = CastarrColors.fg, contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.surfaceFocused, focusedContainerColor = CastarrColors.surfaceFocused,
focusedContentColor = CastarrColors.fg, focusedContentColor = CastarrColors.fg,
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, CastarrColors.accent),
shape = RoundedCornerShape(12.dp),
),
),
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
"${index + 1}", "$number",
color = if (playing) CastarrColors.accent else CastarrColors.faint, color = if (playing) CastarrColors.accent else CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier.width(44.dp), modifier = Modifier.width(40.dp),
) )
if (favorite) { LogoTile(channel)
Text( Spacer(Modifier.width(14.dp))
"", Row(
color = CastarrColors.accent, verticalAlignment = Alignment.CenterVertically,
fontSize = 13.sp, modifier = Modifier.width(230.dp),
modifier = Modifier.width(20.dp), ) {
)
}
Column(Modifier.weight(1f)) {
Text( Text(
channel.name, channel.name,
color = if (playing) CastarrColors.accent else CastarrColors.fg, color = if (playing) CastarrColors.accent else CastarrColors.fg,
@@ -259,19 +315,15 @@ private fun ChannelRow(
fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal, fontWeight = if (playing) FontWeight.Medium else FontWeight.Normal,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
if (showGroup && channel.group.isNotEmpty()) { if (favorite) {
Text( Spacer(Modifier.width(8.dp))
channel.group, Text("", color = CastarrColors.accent, fontSize = 13.sp)
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
NowNextCell(nowNext) Spacer(Modifier.width(24.dp))
EpgCell(nowNext, Modifier.weight(1f))
if (playing) { if (playing) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Box( Box(
@@ -285,25 +337,82 @@ private fun ChannelRow(
} }
} }
/** Channel logo in a small tile; initials while loading or without a logo. */
@Composable @Composable
private fun NowNextCell(nowNext: NowNext) { private fun LogoTile(channel: Channel) {
val now = nowNext.now val initials = channel.name.split(" ").filter { it.isNotBlank() }
Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(320.dp)) { .take(2).map { it.first() }.joinToString("").uppercase()
if (now != null) { Box(
Text( Modifier
now.title, .size(38.dp)
color = CastarrColors.muted, .clip(RoundedCornerShape(8.dp))
fontFamily = AppFont, .background(CastarrColors.surfaceFocused),
fontSize = 13.sp, contentAlignment = Alignment.Center,
maxLines = 1, ) {
overflow = TextOverflow.Ellipsis, if (channel.logo.isNotEmpty()) {
SubcomposeAsyncImage(
model = channel.logo,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(5.dp),
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.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() / val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f) (now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(6.dp))
Box( Box(
Modifier Modifier
.width(180.dp) .fillMaxWidth()
.height(2.dp) .height(2.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.dp))
.background(CastarrColors.line) .background(CastarrColors.line)
@@ -315,13 +424,16 @@ private fun NowNextCell(nowNext: NowNext) {
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
} }
nowNext.next?.let { next -> val next = nowNext.next?.takeUnless {
Spacer(Modifier.height(4.dp)) isEpgPlaceholder(it.title) || it.title == now.title
}
if (next != null) {
Spacer(Modifier.height(5.dp))
Text( Text(
"danach: ${next.title}", "danach: ${next.title}",
color = CastarrColors.faint, color = CastarrColors.faint,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 11.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )

View File

@@ -17,7 +17,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -26,8 +29,14 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path 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.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -38,6 +47,7 @@ import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import dev.castarr.tv.AppState import dev.castarr.tv.AppState
import dev.castarr.tv.data.isEpgPlaceholder
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -95,14 +105,27 @@ private fun formatClock(millis: Long): String =
private fun Overlay(state: AppState) { private fun Overlay(state: AppState) {
val playFocus = remember { FocusRequester() } val playFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { playFocus.requestFocus() } 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( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
// Any key while the overlay is open keeps it open a while longer. // Any key while the overlay is open keeps it open a while longer.
.onPreviewKeyEvent { .onPreviewKeyEvent { event ->
state.pingOverlay() state.pingOverlay()
false 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( Box(
@@ -140,37 +163,26 @@ private fun Overlay(state: AppState) {
.padding(horizontal = 36.dp) .padding(horizontal = 36.dp)
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { // No status word while simply playing, and no LIVE badge — a
if (state.isLive) { // red dot reads as "recording" (user feedback).
Box( val statusWord = when (state.playerState) {
Modifier "paused" -> "Pausiert"
.size(6.dp) "buffering" -> "Lädt…"
.clip(CircleShape) "error" -> "Wiedergabefehler"
.background(CastarrColors.live) else -> null
) }
Spacer(Modifier.width(8.dp)) if (statusWord != null) {
Row {
Spacer(Modifier.weight(1f))
Text( Text(
"LIVE", statusWord,
color = CastarrColors.muted, color = CastarrColors.muted,
fontFamily = AppFont, fontFamily = AppFont,
fontSize = 11.sp, fontSize = 12.sp,
letterSpacing = 2.sp,
) )
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.height(4.dp))
Text(
when (state.playerState) {
"paused" -> "Pausiert"
"buffering" -> "Lädt…"
"error" -> "Wiedergabefehler"
else -> "Wird abgespielt"
},
color = CastarrColors.muted,
fontFamily = AppFont,
fontSize = 12.sp,
)
} }
Spacer(Modifier.height(4.dp))
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,
@@ -180,7 +192,7 @@ private fun Overlay(state: AppState) {
) )
val info = state.currentChannel?.let { state.nowNext(it) } val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
if (now != null) { if (now != null) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
@@ -199,7 +211,9 @@ private fun Overlay(state: AppState) {
) )
} }
} }
info?.next?.let { next -> info?.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now?.title
}?.let { next ->
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
"Danach: ${next.title} (${formatClock(next.start)})", "Danach: ${next.title} (${formatClock(next.start)})",
@@ -236,13 +250,13 @@ private fun Overlay(state: AppState) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
PillButton( IconButton(
label = if (state.playerState == "paused") "Weiter" else "Pause",
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE, icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus, focusRequester = playFocus,
) { state.player.toggle() } ) { state.player.toggle() }
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
PillButton(label = "Beenden", icon = PillIcon.STOP) { state.stopPlayback() } IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() }
if (state.audioTracks.size > 1) { if (state.audioTracks.size > 1) {
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
val current = state.audioTracks.firstOrNull { it.selected } val current = state.audioTracks.firstOrNull { it.selected }
@@ -262,10 +276,59 @@ private fun Overlay(state: AppState) {
private enum class PillIcon { PLAY, PAUSE, STOP } 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 @Composable
private fun PillButton( private fun PillButton(
label: String, label: String,
icon: PillIcon? = null,
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@@ -280,45 +343,12 @@ private fun PillButton(
focusedContentColor = CastarrColors.onAccent, focusedContentColor = CastarrColors.onAccent,
), ),
) { ) {
Row( Text(
label,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically, )
) {
if (icon != null) {
// Drawn, not typed — glyphs like U+23F8 render as emoji on
// Android and have no business on this screen.
val color = LocalContentColor.current
Canvas(Modifier.size(11.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.32f
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)
}
}
Spacer(Modifier.width(9.dp))
}
Text(
label,
fontFamily = AppFont,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
} }
} }

View File

@@ -1,19 +1,26 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -23,10 +30,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext 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.dp
import androidx.compose.ui.unit.sp 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.ClickableSurfaceDefaults
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
@@ -37,9 +52,18 @@ import dev.castarr.tv.pairing.Qr
import dev.castarr.tv.update.UpdateChecker import dev.castarr.tv.update.UpdateChecker
import kotlinx.coroutines.launch 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 * Google-TV-style settings: cards with full-width focusable rows; choices
* fields, no scrolling. Everything text-heavy lives in the Erweitert screen. * open a picker dialog (Konto / Wiedergabe / App / Handy-Fernbedienung).
* Ten-Foot-Regel (#14): no text fields, no scrolling.
*/ */
@Composable @Composable
fun SettingsScreen(state: AppState) { fun SettingsScreen(state: AppState) {
@@ -47,132 +71,384 @@ fun SettingsScreen(state: AppState) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val profiles by state.dispatcharr.profiles.collectAsState() val profiles by state.dispatcharr.profiles.collectAsState()
var updateStatus by remember { mutableStateOf("") } var updateStatus by remember { mutableStateOf("") }
var picker by remember { mutableStateOf<Picker?>(null) }
Row( Row(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp) .padding(horizontal = 40.dp, vertical = 12.dp)
) { ) {
Column(Modifier.weight(1.2f)) { Column(Modifier.weight(1.25f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
SectionTitle("Konto") SettingsCard("Konto") {
Spacer(Modifier.height(8.dp)) if (state.auth.isLoggedIn) {
if (state.auth.isLoggedIn) { Row(
Text( modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
"Angemeldet als ${state.auth.username}", verticalAlignment = Alignment.CenterVertically,
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp, ) {
) Box(
Spacer(Modifier.height(14.dp)) Modifier
if (state.source.m3uUrl.isNotEmpty()) { .size(34.dp)
Row { .clip(CircleShape)
Chip("Dispatcharr", state.sourceMode == AppState.SourceMode.DISPATCHARR) { .background(CastarrColors.accentDim),
state.setMode(AppState.SourceMode.DISPATCHARR) contentAlignment = Alignment.Center,
) {
Text(
state.auth.username.take(1).uppercase().ifEmpty { "?" },
color = CastarrColors.accent,
fontFamily = AppFont,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(14.dp))
Chip("Eigene M3U", state.sourceMode == AppState.SourceMode.GENERIC) { Column {
state.setMode(AppState.SourceMode.GENERIC) 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)) SettingRow("Abmelden", danger = true) { state.startOnboarding() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
SettingRow("Jetzt anmelden") { state.startOnboarding() }
} }
Text("Stream-Qualität", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
Row {
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
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() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
)
Spacer(Modifier.height(12.dp))
ActionButton("Jetzt anmelden") { state.startOnboarding() }
} }
Spacer(Modifier.height(28.dp)) SettingsCard("Wiedergabe") {
SectionTitle("App") if (state.auth.isLoggedIn && state.source.m3uUrl.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) val modes = listOf("Dispatcharr", "Eigene M3U")
Text( val modeIndex = if (state.sourceMode == AppState.SourceMode.DISPATCHARR) 0 else 1
"Version ${BuildConfig.VERSION_NAME}" + (state.updateAvailable?.let { " · $it verfügbar" } ?: ""), SettingRow(
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp, "Quelle",
) subtitle = "Woher die Senderliste kommt",
Spacer(Modifier.height(8.dp)) trailing = { ValueWithCaret(modes[modeIndex]) },
Row { ) {
ActionButton( picker = Picker("Quelle", modes, modeIndex) { index ->
if (state.updateAvailable != null) "Update installieren" else "Nach Update suchen" state.setMode(
) { if (index == 0) AppState.SourceMode.DISPATCHARR
scope.launch { else AppState.SourceMode.GENERIC
updateStatus = if (state.updateAvailable != null) { )
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…" }
} else { }
when (val v = UpdateChecker.check(state)) { }
null -> "App ist aktuell" val qualities = listOf("Standard", "Original") + profiles
else -> "$v verfügbar — nochmal drücken zum Installieren" 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) { "" }
} }
} }
} }
Spacer(Modifier.width(8.dp))
ActionButton("Erweitert") { state.screen = AppState.Screen.ADVANCED }
} }
if (updateStatus.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) SettingsCard("App") {
Text(updateStatus, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp) 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 = when (val v = UpdateChecker.check(state)) {
null -> "App ist aktuell"
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…"
}
}
}
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) { Column(Modifier.weight(0.75f)) {
SectionTitle("Handy-Fernbedienung") SettingsCard("Handy-Fernbedienung") {
Spacer(Modifier.height(10.dp)) val address = remember { Pairing.lanAddress() }
val address = remember { Pairing.lanAddress() } if (address != null) {
if (address != null) { val qr = remember(address) {
val qr = remember(address) { Qr.encode(
Qr.encode(Pairing.pairingUrl(context, address), 400, android.graphics.Color.parseColor("#101216")) 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(
"Mit der Handy-Kamera scannen",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
Text(
"${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),
)
} }
QrCard(qr)
Spacer(Modifier.height(10.dp))
Text(
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
)
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp)
} }
} }
} }
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 @Composable
private fun SectionTitle(text: String) { private fun SettingsCard(title: String, content: @Composable () -> Unit) {
Text(text, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 20.sp) Column(
} Modifier
.fillMaxWidth()
@Composable .clip(RoundedCornerShape(16.dp))
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) { .background(CastarrColors.surface)
Surface( .padding(horizontal = 10.dp, vertical = 14.dp)
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( Text(
label, title.uppercase(),
fontFamily = AppFont, fontSize = 12.sp, color = CastarrColors.faint,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), 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()
}
}
} }
} }
@@ -198,17 +474,27 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
@Composable @Composable
fun QrCard(bitmap: Bitmap) { 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( Column(
Modifier Modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(18.dp))
.background(androidx.compose.ui.graphics.Color(0xFFFBFCFD)) .background(CastarrColors.bg)
.padding(14.dp), .border(1.dp, CastarrColors.line, RoundedCornerShape(18.dp))
.padding(8.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Image( Column(
bitmap = bitmap.asImageBitmap(), Modifier
contentDescription = "QR-Code zum Koppeln", .clip(RoundedCornerShape(12.dp))
modifier = Modifier.size(170.dp), .background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
) .padding(10.dp),
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR-Code zum Koppeln",
modifier = Modifier.size(164.dp),
)
}
} }
} }

View File

@@ -13,11 +13,17 @@ import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL 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 { object UpdateChecker {
private const val TAG = "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 = "" private var apkUrl: String = ""
@@ -35,6 +41,7 @@ object UpdateChecker {
break break
} }
} }
if (url.isEmpty() && tag.isNotEmpty()) url = APK_FALLBACK
if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) { if (url.isNotEmpty() && isNewer(tag, BuildConfig.VERSION_NAME)) {
apkUrl = url apkUrl = url
val version = "v$tag" val version = "v$tag"
@@ -69,7 +76,9 @@ object UpdateChecker {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) 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 null
}.getOrElse { }.getOrElse {
Log.w(TAG, "install failed: ${it.javaClass.simpleName}") Log.w(TAG, "install failed: ${it.javaClass.simpleName}")