Compare commits
10 Commits
095472cb7f
...
17ff402de0
| Author | SHA1 | Date | |
|---|---|---|---|
| 17ff402de0 | |||
| 6ee7f53e3f | |||
| e2e841d49f | |||
| 4aa5a85562 | |||
| ac3612ac65 | |||
| c394d8e946 | |||
| d7da1b2ff4 | |||
| 22bda1644f | |||
| b59c3deee4 | |||
| b13caf41c2 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
Text(
|
|
||||||
now.title,
|
|
||||||
color = CastarrColors.muted,
|
|
||||||
fontFamily = AppFont,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val fraction = ((System.currentTimeMillis() - now.start).toFloat() /
|
|
||||||
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
|
||||||
Spacer(Modifier.height(5.dp))
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(180.dp)
|
.size(38.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(CastarrColors.surfaceFocused),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
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() /
|
||||||
|
(now.stop - now.start).coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
val select = event.key == Key.DirectionCenter ||
|
||||||
|
event.key == Key.Enter || event.key == Key.NumPadEnter
|
||||||
|
when {
|
||||||
|
select && event.type == KeyEventType.KeyDown -> {
|
||||||
|
sawSelectDown = true
|
||||||
false
|
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
|
|
||||||
.size(6.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(CastarrColors.live)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
"LIVE",
|
|
||||||
color = CastarrColors.muted,
|
|
||||||
fontFamily = AppFont,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
letterSpacing = 2.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
when (state.playerState) {
|
|
||||||
"paused" -> "Pausiert"
|
"paused" -> "Pausiert"
|
||||||
"buffering" -> "Lädt…"
|
"buffering" -> "Lädt…"
|
||||||
"error" -> "Wiedergabefehler"
|
"error" -> "Wiedergabefehler"
|
||||||
else -> "Wird abgespielt"
|
else -> null
|
||||||
},
|
}
|
||||||
|
if (statusWord != null) {
|
||||||
|
Row {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
statusWord,
|
||||||
color = CastarrColors.muted,
|
color = CastarrColors.muted,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
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(
|
|
||||||
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(
|
Text(
|
||||||
label,
|
label,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,133 +71,385 @@ 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(
|
||||||
|
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(
|
Text(
|
||||||
"Angemeldet als ${state.auth.username}",
|
state.auth.username.take(1).uppercase().ifEmpty { "?" },
|
||||||
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
|
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))
|
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() }
|
||||||
}
|
|
||||||
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 {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Nicht angemeldet.",
|
"Nicht angemeldet.",
|
||||||
color = CastarrColors.muted, fontFamily = AppFont, 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))
|
SettingRow("Jetzt anmelden") { state.startOnboarding() }
|
||||||
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",
|
||||||
|
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 {
|
scope.launch {
|
||||||
updateStatus = if (state.updateAvailable != null) {
|
updateStatus = when (val v = UpdateChecker.check(state)) {
|
||||||
UpdateChecker.downloadAndInstall(context, state) ?: "Update wird geöffnet…"
|
|
||||||
} else {
|
|
||||||
when (val v = UpdateChecker.check(state)) {
|
|
||||||
null -> "App ist aktuell"
|
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))
|
SettingRow(
|
||||||
Text(updateStatus, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 12.sp)
|
"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(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)
|
QrCard(qr)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
"Mit der Handy-Kamera scannen",
|
||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionTitle(text: String) {
|
|
||||||
Text(text, color = CastarrColors.fg, fontFamily = AppFont, 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(
|
Text(
|
||||||
label,
|
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
|
||||||
fontFamily = AppFont, fontSize = 12.sp,
|
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),
|
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
|
@Composable
|
||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(androidx.compose.ui.graphics.Color(0xFFF4F6F7))
|
||||||
|
.padding(10.dp),
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap.asImageBitmap(),
|
bitmap = bitmap.asImageBitmap(),
|
||||||
contentDescription = "QR-Code zum Koppeln",
|
contentDescription = "QR-Code zum Koppeln",
|
||||||
modifier = Modifier.size(170.dp),
|
modifier = Modifier.size(164.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user