18 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 00:05:25 +02:00
be-nj
b12bfdf6e8 Key-hint row in player overlay instead of on-screen buttons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 23:59:11 +02:00
be-nj
0410eda9c9 Player overlay v2: now/next with programme progress, audio track switching
- Overlay shows current programme (title, time range, EPG progress) and
  what's next; resurfaces briefly on programme change, hides after 5s.
- Audio tracks: listed from the stream, switchable via D-pad left/right
  on live streams (seek keeps left/right on seekable media), via chips
  in the overlay and from the phone remote (set_audio message).
- Main UI font switches to Inter; Space Grotesk stays for the wordmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 23:54:02 +02:00
be-nj
e69c79061f Version 0.3.1 2026-08-25 23:42:16 +02:00
21 changed files with 1254 additions and 303 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 = 6 versionCode = 21
versionName = "0.3.0" versionName = "0.8.5"
} }
// 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

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

View File

@@ -38,6 +38,22 @@ class AppState(
var isLive by mutableStateOf(false) var isLive by mutableStateOf(false)
var favoritesOnly by mutableStateOf(false) var favoritesOnly by mutableStateOf(false)
var groupFilter by mutableStateOf<String?>(null) var groupFilter by mutableStateOf<String?>(null)
var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
private set
private var lastNowTitle: String? = null
/** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("")
/** Bumped on every interaction with the visible overlay to restart the
* auto-hide timer. */
var overlayPing by mutableLongStateOf(0L)
private set
fun pingOverlay() {
overlayVisible = true
overlayPing++
}
/** Phone-first onboarding progress (ADR-0007). */ /** Phone-first onboarding progress (ADR-0007). */
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE) var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
@@ -158,13 +174,31 @@ class AppState(
} }
fun syncFromPlayer() { fun syncFromPlayer() {
val previous = playerState
playerState = player.state playerState = player.state
if (playerState != previous) overlayVisible = true
isLive = player.player.isCurrentMediaItemLive isLive = player.player.isCurrentMediaItemLive
positionMs = player.player.currentPosition.coerceAtLeast(0) positionMs = player.player.currentPosition.coerceAtLeast(0)
durationMs = player.player.duration.coerceAtLeast(0) durationMs = player.player.duration.coerceAtLeast(0)
audioTracks = player.audioTracks()
// Briefly resurface the overlay when the running programme changes.
val nowTitle = currentChannel?.let { nowNext(it).now?.title }
if (playerVisible && nowTitle != null && lastNowTitle != null && nowTitle != lastNowTitle) {
overlayVisible = true
}
lastNowTitle = nowTitle
if (!player.hasMedia && playerVisible) { if (!player.hasMedia && playerVisible) {
playerVisible = false playerVisible = false
currentChannel = null currentChannel = null
} }
} }
/** Steps to the next audio track (D-pad friendly: one key, cycles). */
fun cycleAudio(direction: Int) {
val tracks = audioTracks
if (tracks.size < 2) return
val current = tracks.indexOfFirst { it.selected }.coerceAtLeast(0)
player.selectAudio((current + direction + tracks.size) % tracks.size)
audioTracks = player.audioTracks()
}
} }

View File

@@ -115,6 +115,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("duration", player.player.duration.coerceAtLeast(0)) .put("duration", player.player.duration.coerceAtLeast(0))
.put("volume", if (max > 0) vol.toDouble() / max else 0.0) .put("volume", if (max > 0) vol.toDouble() / max else 0.0)
.put("error", player.errorMessage ?: "") .put("error", player.errorMessage ?: "")
.put("audioTracks", org.json.JSONArray().also { arr ->
state.audioTracks.forEach { arr.put(it.label) }
})
.put("audioSelected", state.audioTracks.indexOfFirst { it.selected })
} }
override fun currentChannels(): List<Channel> = state.activeChannels() override fun currentChannels(): List<Channel> = state.activeChannels()
@@ -194,10 +198,26 @@ 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
// there via normal Compose focus); with the overlay open
// this key is consumed by the focused button and never
// reaches the activity.
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_ENTER -> {
state.pingOverlay()
return true
}
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
state.player.toggle() state.player.toggle()
return true return true
@@ -224,18 +244,24 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.zap(1) state.zap(1)
return true return true
} }
// With the overlay open, left/right moves button focus and
// never reaches the activity. Hidden overlay: seek on
// seekable media, otherwise just bring the controls up.
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND -> { KeyEvent.KEYCODE_MEDIA_REWIND -> {
onSeek(-SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(-SEEK_STEP_SECONDS)
else state.pingOverlay()
return true return true
} }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
onSeek(SEEK_STEP_SECONDS) if (state.player.player.isCurrentMediaItemSeekable) onSeek(SEEK_STEP_SECONDS)
else state.pingOverlay()
return true return true
} }
KeyEvent.KEYCODE_BACK -> { KeyEvent.KEYCODE_BACK -> {
state.stopPlayback() if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true return true
} }
else -> Unit else -> Unit
@@ -244,6 +270,40 @@ 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) {
state.overlayVisible = true
state.cycleAudio(direction)
server.broadcastStatus()
}
override fun onSetAudio(index: Int) {
if (index < 0) return
state.player.selectAudio(index)
state.syncFromPlayer()
state.overlayVisible = true
server.broadcastStatus()
}
// --- lifecycle --- // --- lifecycle ---
override fun onStop() { override fun onStop() {
@@ -262,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

@@ -1,12 +1,16 @@
package dev.castarr.tv.player package dev.castarr.tv.player
import android.content.Context import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import java.util.Locale
/** /**
* Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause * Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause
@@ -30,12 +34,17 @@ class PlayerController(
private var retryCount = 0 private var retryCount = 0
private var lastUrl: String = "" private var lastUrl: String = ""
/** One selectable audio track of the current stream. */
data class AudioTrack(val label: String, val selected: Boolean)
init { init {
player.addListener(object : Player.Listener { player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) = onChanged() override fun onPlaybackStateChanged(playbackState: Int) = onChanged()
override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged() override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged()
override fun onTracksChanged(tracks: Tracks) = onChanged()
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
// Self-heal: silently reconnect twice before surfacing (#13). // Self-heal: silently reconnect twice before surfacing (#13).
if (retryCount < 2 && lastUrl.isNotEmpty()) { if (retryCount < 2 && lastUrl.isNotEmpty()) {
@@ -105,6 +114,48 @@ class PlayerController(
onChanged() onChanged()
} }
/** Audio tracks of the current stream, in group order. */
fun audioTracks(): List<AudioTrack> {
val result = mutableListOf<AudioTrack>()
val seen = mutableSetOf<String>()
player.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_AUDIO }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
var label = format.label
?: format.language?.let { Locale(it).getDisplayLanguage(Locale.GERMAN) }
?.replaceFirstChar { c -> c.uppercase() }
?: "Ton ${result.size + 1}"
if (!seen.add(label)) label = "$label ${result.size + 1}"
result.add(AudioTrack(label, group.isTrackSelected(i)))
}
}
return result
}
/** Selects the audio track at [index] of [audioTracks]'s order. */
fun selectAudio(index: Int) {
var flat = 0
player.currentTracks.groups
.filter { it.type == C.TRACK_TYPE_AUDIO }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
if (flat == index) {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, i))
.build()
onChanged()
return
}
flat++
}
}
}
fun seekBy(deltaSeconds: Long) { fun seekBy(deltaSeconds: Long) {
if (!hasMedia || !player.isCurrentMediaItemSeekable) return if (!hasMedia || !player.isCurrentMediaItemSeekable) return
val target = (player.currentPosition + deltaSeconds * 1000) val target = (player.currentPosition + deltaSeconds * 1000)

View File

@@ -32,6 +32,7 @@ class ControlServer(
fun onResumeCast() fun onResumeCast()
fun onStopCast() fun onStopCast()
fun onSeek(deltaSeconds: Long) fun onSeek(deltaSeconds: Long)
fun onSetAudio(index: Int)
fun onVolume(value: Float) fun onVolume(value: Float)
fun onSetPlaylist(url: String) fun onSetPlaylist(url: String)
fun onToggleFavorite(channelId: Int) fun onToggleFavorite(channelId: Int)
@@ -201,6 +202,7 @@ class ControlServer(
"resume" -> post { listener.onResumeCast() } "resume" -> post { listener.onResumeCast() }
"stop" -> post { listener.onStopCast() } "stop" -> post { listener.onStopCast() }
"seek" -> post { listener.onSeek(msg.optLong("delta")) } "seek" -> post { listener.onSeek(msg.optLong("delta")) }
"set_audio" -> post { listener.onSetAudio(msg.optInt("index", -1)) }
"volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) } "volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
"set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) } "set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
"set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) } "set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }

View File

@@ -46,18 +46,18 @@ fun AdvancedScreen(state: AppState) {
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 40.dp, vertical = 20.dp) .padding(horizontal = 40.dp, vertical = 20.dp)
) { ) {
Text("Erweitert", color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 22.sp) Text("Erweitert", color = CastarrColors.fg, fontFamily = AppFont, fontSize = 22.sp)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
"Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " + "Eigene M3U-Quelle (ErsatzTV, Threadfin, Tunarr …) — optionales XMLTV-EPG. " +
"Server: ${state.auth.serverUrl.ifEmpty { "—" }}", "Server: ${state.auth.serverUrl.ifEmpty { "—" }}",
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
) )
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
TvTextField( TvTextField(
value = m3u, value = m3u,
onValueChange = { m3u = it }, onValueChange = { m3u = it },
label = { Text("M3U-URL", fontFamily = SpaceGrotesk) }, label = { Text("M3U-URL", fontFamily = AppFont) },
colors = fieldColors, colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp), textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp), modifier = Modifier.width(600.dp),
@@ -66,7 +66,7 @@ fun AdvancedScreen(state: AppState) {
TvTextField( TvTextField(
value = epg, value = epg,
onValueChange = { epg = it }, onValueChange = { epg = it },
label = { Text("XMLTV-EPG-URL (optional)", fontFamily = SpaceGrotesk) }, label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
colors = fieldColors, colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp), textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp), modifier = Modifier.width(600.dp),
@@ -92,7 +92,7 @@ fun AdvancedScreen(state: AppState) {
else -> message else -> message
} }
if (statusText.isNotEmpty()) { if (statusText.isNotEmpty()) {
Text(statusText, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp) Text(statusText, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
} }
} }
} }

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,11 +28,23 @@ 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) {
// Back always leads home instead of quitting the app by accident.
androidx.activity.compose.BackHandler(
enabled = !state.playerVisible && state.screen != AppState.Screen.LIVE
&& state.screen != AppState.Screen.WELCOME
) {
state.screen = if (state.screen == AppState.Screen.ADVANCED) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -48,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,
)
} }
} }
@@ -65,14 +118,6 @@ private fun TopBar(state: AppState) {
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
letterSpacing = 4.sp, letterSpacing = 4.sp,
) )
Spacer(Modifier.width(36.dp))
TabItem("Live", state.screen == AppState.Screen.LIVE) {
state.screen = AppState.Screen.LIVE
}
Spacer(Modifier.width(10.dp))
TabItem("Einstellungen", state.screen == AppState.Screen.SETTINGS) {
state.screen = AppState.Screen.SETTINGS
}
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
state.connectedRemote?.let { name -> state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -83,30 +128,59 @@ private fun TopBar(state: AppState) {
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 13.sp) Text(name, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 13.sp)
} }
Spacer(Modifier.width(18.dp))
}
GearButton {
state.screen = if (state.screen == AppState.Screen.LIVE) AppState.Screen.SETTINGS
else AppState.Screen.LIVE
} }
} }
} }
/** Round settings button with a drawn gear (glyphs render as emoji). */
@Composable @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 = SpaceGrotesk, 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

@@ -55,11 +55,11 @@ fun ErrorScreen(state: AppState) {
"Neu anmelden", "Neu anmelden",
) )
} }
Text(title, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 30.sp) Text(title, color = CastarrColors.fg, fontFamily = AppFont, fontSize = 30.sp)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
subtitle, subtitle,
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 15.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 15.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(26.dp)) Spacer(Modifier.height(26.dp))
@@ -78,7 +78,7 @@ fun ErrorScreen(state: AppState) {
) { ) {
Text( Text(
buttonLabel, buttonLabel,
fontFamily = SpaceGrotesk, fontSize = 16.sp, fontFamily = AppFont, fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp), modifier = Modifier.padding(horizontal = 26.dp, vertical = 13.dp),
) )
} }

View File

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

View File

@@ -1,6 +1,6 @@
package dev.castarr.tv.ui package dev.castarr.tv.ui
import android.view.KeyEvent import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,7 +17,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.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
@@ -25,29 +28,51 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester 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.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
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.type
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
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
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.Surface
import androidx.tv.material3.Text import 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.util.Date
import java.util.Locale
/** /**
* Fullscreen playback with the minimal auto-hiding overlay. Key handling for * Fullscreen playback. OK opens the auto-hiding overlay whose transport
* play/pause/zapping lives in MainActivity.onKeyDown; this composable only * buttons are ordinary focusable controls; media/channel keys are handled
* grabs focus so list items underneath stop reacting. * in MainActivity.onKeyDown.
*/ */
@Composable @Composable
fun PlayerScreen(state: AppState) { fun PlayerScreen(state: AppState) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() } LaunchedEffect(state.overlayVisible) {
LaunchedEffect(state.playerVisible, state.playerState) { // While the overlay is hidden the root grabs focus so list items
state.overlayVisible = true // underneath stop reacting; the overlay's play button takes over
if (state.playerState == "playing") { // when it appears.
kotlinx.coroutines.delay(4000) if (!state.overlayVisible) focusRequester.requestFocus()
}
LaunchedEffect(state.playerVisible, state.playerState, state.overlayVisible, state.overlayPing) {
if (state.overlayVisible && state.playerState == "playing") {
kotlinx.coroutines.delay(5000)
state.overlayVisible = false state.overlayVisible = false
} }
} }
@@ -76,17 +101,49 @@ fun PlayerScreen(state: AppState) {
} }
} }
/** Soft drop shadow so overlay text stays readable on bright video. */
private val OverlayText = TextStyle(
shadow = Shadow(Color(0xB3000000), Offset(0f, 2f), blurRadius = 8f),
)
private fun formatClock(millis: Long): String =
SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(millis))
@Composable @Composable
private fun Overlay(state: AppState) { private fun Overlay(state: AppState) {
Box(Modifier.fillMaxSize()) { val playFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { playFocus.requestFocus() }
// The key-UP of the very press that opened this overlay arrives after
// the play button grabbed focus and would click it (instant pause).
// Swallow select-releases whose press the overlay never saw.
var sawSelectDown by remember { mutableStateOf(false) }
Box(
Modifier
.fillMaxSize()
// Any key while the overlay is open keeps it open a while longer.
.onPreviewKeyEvent { event ->
state.pingOverlay()
val select = event.key == Key.DirectionCenter ||
event.key == Key.Enter || event.key == Key.NumPadEnter
when {
select && event.type == KeyEventType.KeyDown -> {
sawSelectDown = true
false
}
select && event.type == KeyEventType.KeyUp && !sawSelectDown -> true
else -> false
}
},
) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(280.dp)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.92f)) listOf(Color.Transparent, CastarrColors.bgDeep.copy(alpha = 0.94f))
) )
) )
) )
@@ -97,7 +154,8 @@ private fun Overlay(state: AppState) {
.padding(top = 22.dp, end = 30.dp), .padding(top = 22.dp, end = 30.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(name, color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 12.sp) Text(name, color = CastarrColors.muted, fontFamily = AppFont,
style = OverlayText, fontSize = 12.sp)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
@@ -112,63 +170,219 @@ private fun Overlay(state: AppState) {
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 36.dp) .padding(horizontal = 36.dp)
.padding(bottom = 30.dp), .padding(bottom = 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 = SpaceGrotesk,
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 = SpaceGrotesk, fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp, fontSize = 12.sp,
) )
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
}
Row(verticalAlignment = Alignment.Bottom) {
// Channel number inline before the name (matches the list
// and the number-pad zapping).
val number = state.currentChannel?.let { current ->
state.activeChannels().indexOfFirst { it.url == current.url } + 1
} ?: 0
if (number > 0) {
Text(
"$number",
color = CastarrColors.accent,
fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(14.dp))
}
Text( Text(
state.currentChannel?.name.orEmpty(), state.currentChannel?.name.orEmpty(),
color = CastarrColors.fg, color = CastarrColors.fg,
fontFamily = SpaceGrotesk, fontFamily = AppFont,
style = OverlayText,
fontSize = 30.sp, fontSize = 30.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(16.dp)) }
val fraction = if (state.isLive || state.durationMs <= 0) 1f
else (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) val info = state.currentChannel?.let { state.nowNext(it) }
val now = info?.now?.takeUnless { isEpgPlaceholder(it.title) }
if (now != null) {
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.Bottom) {
Text(
now.title,
color = CastarrColors.fg,
fontFamily = AppFont,
style = OverlayText,
fontSize = 18.sp,
)
Spacer(Modifier.width(14.dp))
Text(
"${formatClock(now.start)} ${formatClock(now.stop)}",
color = CastarrColors.muted,
fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp,
)
}
}
info?.next?.takeUnless {
isEpgPlaceholder(it.title) || it.title == now?.title
}?.let { next ->
Spacer(Modifier.height(2.dp))
Text(
"Danach: ${next.title} (${formatClock(next.start)})",
color = CastarrColors.faint,
fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp,
)
}
Spacer(Modifier.height(12.dp))
// Live with EPG: progress of the running programme. Otherwise
// playback position; unknown duration fills the bar.
val fraction = when {
now != null && now.stop > now.start ->
((System.currentTimeMillis() - now.start).toFloat() / (now.stop - now.start))
.coerceIn(0f, 1f)
state.isLive || state.durationMs <= 0 -> 1f
else -> (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f)
}
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(2.dp) .height(3.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.5.dp))
.background(CastarrColors.line) .background(CastarrColors.line)
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth(fraction) .fillMaxWidth(fraction)
.height(2.dp) .height(3.dp)
.background(CastarrColors.accent) .background(CastarrColors.accent)
) )
} }
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
icon = if (state.playerState == "paused") PillIcon.PLAY else PillIcon.PAUSE,
contentDescription = if (state.playerState == "paused") "Weiter" else "Pause",
focusRequester = playFocus,
) { state.player.toggle() }
Spacer(Modifier.width(10.dp))
IconButton(icon = PillIcon.STOP, contentDescription = "Beenden") { state.stopPlayback() }
if (state.audioTracks.size > 1) {
Spacer(Modifier.width(10.dp))
val current = state.audioTracks.firstOrNull { it.selected }
PillButton(label = "Ton: ${current?.label ?: ""}") { state.cycleAudio(1) }
}
Spacer(Modifier.weight(1f))
Text(
"▲ ▼ Sender wechseln",
color = CastarrColors.faint,
fontFamily = AppFont,
style = OverlayText,
fontSize = 12.sp,
)
}
} }
} }
} }
private enum class PillIcon { PLAY, PAUSE, STOP }
/** Round icon-only transport button; the icons are drawn, not typed —
* glyphs like U+23F8 render as emoji on Android. */
@Composable
private fun IconButton(
icon: PillIcon,
contentDescription: String,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = (if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.semantics { this.contentDescription = contentDescription },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
val color = LocalContentColor.current
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.size(15.dp)) {
when (icon) {
PillIcon.PLAY -> drawPath(
Path().apply {
moveTo(0f, 0f)
lineTo(size.width, size.height / 2f)
lineTo(0f, size.height)
close()
},
color,
)
PillIcon.PAUSE -> {
val bar = size.width * 0.3f
drawRect(color, size = androidx.compose.ui.geometry.Size(bar, size.height))
drawRect(
color,
topLeft = androidx.compose.ui.geometry.Offset(size.width - bar, 0f),
size = androidx.compose.ui.geometry.Size(bar, size.height),
)
}
PillIcon.STOP -> drawRect(color)
}
}
}
}
}
@Composable
private fun PillButton(
label: String,
focusRequester: FocusRequester? = null,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = CastarrColors.surface.copy(alpha = 0.75f),
contentColor = CastarrColors.fg,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text(
label,
fontFamily = AppFont,
style = OverlayText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 9.dp),
)
}
}

View File

@@ -1,19 +1,26 @@
package dev.castarr.tv.ui 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 = SpaceGrotesk, 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 = SpaceGrotesk, fontSize = 12.sp)
Spacer(Modifier.height(6.dp))
Row {
Chip("Standard", state.outputProfile.isEmpty()) { state.outputProfile = "" }
Spacer(Modifier.width(8.dp))
Chip("Original", state.outputProfile == "raw") { state.outputProfile = "raw" }
profiles.forEach { name ->
Spacer(Modifier.width(8.dp))
Chip(name, state.outputProfile == name) { state.outputProfile = name }
}
}
Spacer(Modifier.height(18.dp))
ActionButton("Abmelden", danger = true) { state.startOnboarding() }
} else { } else {
Text( Text(
"Nicht angemeldet.", "Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = SpaceGrotesk, fontSize = 14.sp, color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
) )
Spacer(Modifier.height(12.dp)) 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 = SpaceGrotesk, 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 = SpaceGrotesk, 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 = SpaceGrotesk, fontSize = 12.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 12.sp,
) )
} else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp)
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, color = CastarrColors.fg, fontFamily = SpaceGrotesk, fontSize = 20.sp)
}
@Composable
fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(999.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) CastarrColors.accentDim else CastarrColors.surface,
contentColor = if (selected) CastarrColors.accent else CastarrColors.muted,
focusedContainerColor = CastarrColors.accent,
focusedContentColor = CastarrColors.onAccent,
),
) {
Text( Text(
label, "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
fontFamily = SpaceGrotesk, 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
@@ -190,7 +466,7 @@ fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
) { ) {
Text( Text(
label, label,
fontFamily = SpaceGrotesk, fontSize = 13.sp, fontFamily = AppFont, fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
) )
} }
@@ -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),
) )
} }
}
} }

View File

@@ -21,6 +21,15 @@ object CastarrColors {
val onAccent = Color(0xFF07110F) val onAccent = Color(0xFF07110F)
} }
/** Display face — only for the CASTARR wordmark. */
val SpaceGrotesk = FontFamily( val SpaceGrotesk = FontFamily(
Font(R.font.space_grotesk, FontWeight.Normal), Font(R.font.space_grotesk, FontWeight.Normal),
) )
/** UI face for everything else (Inter). */
val AppFont = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_semibold, FontWeight.Bold),
)

View File

@@ -49,7 +49,7 @@ fun WelcomeScreen(state: AppState) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
"Willkommen bei Castarr", "Willkommen bei Castarr",
color = CastarrColors.fg, fontFamily = SpaceGrotesk, color = CastarrColors.fg, fontFamily = AppFont,
fontSize = 32.sp, fontWeight = FontWeight.Medium, fontSize = 32.sp, fontWeight = FontWeight.Medium,
) )
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
@@ -90,11 +90,11 @@ fun WelcomeScreen(state: AppState) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
Pairing.remoteUrl(address), Pairing.remoteUrl(address),
color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 13.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
) )
} }
} else { } else {
Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = SpaceGrotesk, fontSize = 15.sp) Text("Keine Netzwerkverbindung", color = CastarrColors.faint, fontFamily = AppFont, fontSize = 15.sp)
} }
} }
} }
@@ -116,19 +116,19 @@ private fun Step(number: Int, text: String, active: Boolean, done: Boolean) {
Text( Text(
if (done) "" else "$number", if (done) "" else "$number",
color = if (active || done) CastarrColors.onAccent else CastarrColors.faint, color = if (active || done) CastarrColors.onAccent else CastarrColors.faint,
fontFamily = SpaceGrotesk, fontSize = 14.sp, fontWeight = FontWeight.Medium, fontFamily = AppFont, fontSize = 14.sp, fontWeight = FontWeight.Medium,
) )
} }
Spacer(Modifier.width(14.dp)) Spacer(Modifier.width(14.dp))
Text( Text(
text, text,
color = if (active) CastarrColors.fg else CastarrColors.muted, color = if (active) CastarrColors.fg else CastarrColors.muted,
fontFamily = SpaceGrotesk, fontSize = 17.sp, fontFamily = AppFont, fontSize = 17.sp,
) )
} }
} }
@Composable @Composable
private fun Status(text: String) { private fun Status(text: String) {
Text(text, color = CastarrColors.accent, fontFamily = SpaceGrotesk, fontSize = 14.sp) Text(text, color = CastarrColors.accent, fontFamily = AppFont, fontSize = 14.sp)
} }

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

Binary file not shown.

Binary file not shown.

Binary file not shown.