Mit dem TV koppeln
- Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.
+ Gib den 4-stelligen Code ein, der auf dem Fernseher unter dem QR-Code steht.
@@ -449,6 +449,9 @@
}
list.innerHTML = '';
const favSet = new Set(state.extras.favorites || []);
+ // Same identity the TV uses: the backend id where there is one, the
+ // position in the full list where there is not (plain M3U playlists).
+ const favId = (c) => (c.backendId ? c.backendId : -(state.channels.indexOf(c) + 1));
const favOn = state.extras.favoritesSupported && state.favOnly;
const teams = state.extras.teams || [];
const club = teams.find((t) => t.key === state.teamKey);
@@ -456,7 +459,7 @@
const clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
const shown = club
? filtered.filter((c) => clubHits.has(c.url))
- : favOn ? filtered.filter((c) => favSet.has(c.backendId)) : filtered;
+ : favOn ? filtered.filter((c) => favSet.has(favId(c))) : filtered;
if (state.extras.favoritesSupported || teams.length) {
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
@@ -543,14 +546,14 @@
}
}
btn.appendChild(num); btn.appendChild(meta);
- if (state.extras.favoritesSupported && c.backendId) {
+ if (state.extras.favoritesSupported) {
const star = document.createElement('span');
- const isFav = favSet.has(c.backendId);
+ const isFav = favSet.has(favId(c));
star.textContent = isFav ? '★' : '☆';
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
star.addEventListener('click', (e) => {
e.stopPropagation();
- send({ type: 'set_favorite', id: c.backendId });
+ send({ type: 'set_favorite', id: favId(c) });
});
btn.appendChild(star);
}
diff --git a/app/src/main/java/dev/castarr/tv/AppState.kt b/app/src/main/java/dev/castarr/tv/AppState.kt
index 7d30737..8b50118 100644
--- a/app/src/main/java/dev/castarr/tv/AppState.kt
+++ b/app/src/main/java/dev/castarr/tv/AppState.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -75,15 +76,86 @@ class AppState(
private set
private var lastNowTitle: String? = null
+ /**
+ * Bumped by the remote's channel keys while the list is open: jumps to
+ * the next or previous initial letter. Right is taken by the day plan,
+ * and a text field is out of the question (ten-foot rule).
+ */
+ var letterJump by mutableIntStateOf(0)
+ private set
+ var letterJumpDirection = 1
+ private set
+
+ fun requestLetterJump(direction: Int) {
+ letterJumpDirection = direction
+ letterJump++
+ }
+
+ /** Everything this channel still shows today, for the day plan. */
+ fun upcomingToday(channel: Channel): List {
+ val now = System.currentTimeMillis()
+ return upcoming(channel, dev.castarr.tv.data.TeamFilters.windowEnd(now) - now)
+ }
+
/** Digits typed on the remote's number pad (channel switching). */
var digitBuffer by mutableStateOf("")
/** False when the control server could not bind its port. */
var remoteAvailable by mutableStateOf(true)
+ /**
+ * Favourites for a generic M3U source. The Dispatcharr fork keeps them
+ * per user on the backend; a plain playlist has nowhere to put them, and
+ * "no way to mark a channel" is not an acceptable answer for the second
+ * source type the app officially supports.
+ */
+ private var localFavorites by mutableStateOf(
+ prefs.getStringSet("favorites", emptySet())?.toSet().orEmpty()
+ )
+
+ /** Marked channels of whichever source is active. */
+ fun favoriteCount(): Int = when (sourceMode) {
+ SourceMode.GENERIC -> localFavorites.size
+ SourceMode.DISPATCHARR -> dispatcharr.favorites.value.size
+ }
+
+ fun isFavorite(channel: Channel): Boolean = when (sourceMode) {
+ SourceMode.GENERIC -> channel.url in localFavorites
+ SourceMode.DISPATCHARR -> channel.backendId in dispatcharr.favorites.value
+ }
+
+ fun toggleFavorite(channel: Channel) {
+ when (sourceMode) {
+ SourceMode.GENERIC -> {
+ localFavorites =
+ if (channel.url in localFavorites) localFavorites - channel.url
+ else localFavorites + channel.url
+ prefs.edit().putStringSet("favorites", localFavorites).apply()
+ }
+ SourceMode.DISPATCHARR -> dispatcharr.toggleFavorite(channel)
+ }
+ }
+
/** Channel to restore focus to when the list comes back (#13). */
var lastWatched by mutableStateOf(null)
+ /**
+ * The same channel across restarts, by URL. Without it the app opens
+ * with the focus on the settings gear, which is not what anyone reaches
+ * for when they switch the TV on.
+ */
+ private var lastWatchedUrl: String = prefs.getString("last_watched", "").orEmpty()
+
+ /** URL the channel list should focus on, or empty for "no idea". */
+ fun restoreTargetUrl(): String = lastWatched?.url ?: lastWatchedUrl
+
+ /**
+ * The channel before the current one, for the jump back that every
+ * remote has. Only set once a second channel has been watched.
+ */
+ var previousChannel by mutableStateOf(null)
+ private set
+
/**
* True while the list is being restored after playback. The group rail
* opens whatever gets focused, so without this the focus landing there
@@ -221,6 +293,59 @@ class AppState(
.sortedBy { it.second.programme.start }
}
+ /**
+ * A club match that has just started somewhere else while the viewer is
+ * watching something. The app knew the kick-off all along and used to
+ * keep it to itself.
+ */
+ data class ClubNudge(
+ val clubName: String,
+ val channel: Channel,
+ val programmeTitle: String,
+ )
+
+ var clubNudge by mutableStateOf(null)
+ private set
+
+ /** Programme already offered, so a dismissed nudge stays dismissed. */
+ private var nudgedProgramme: String = ""
+
+ /**
+ * Offers the first club match that started within the last few minutes
+ * on a channel other than the current one. Called on a timer while
+ * something is playing.
+ */
+ fun checkClubNudge() {
+ if (!playerVisible) { clubNudge = null; return }
+ val now = System.currentTimeMillis()
+ enabledTeams.forEach { key ->
+ val club = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return@forEach
+ teamMatches(key).forEach { (channel, hit) ->
+ val p = hit.programme
+ val justStarted = now - p.start in 0 until NUDGE_GRACE_MS
+ val elsewhere = channel.url != currentChannel?.url
+ val stamp = "${p.start}:${channel.url}"
+ if (justStarted && elsewhere && stamp != nudgedProgramme) {
+ nudgedProgramme = stamp
+ clubNudge = ClubNudge(club.shortName, channel, p.title)
+ return
+ }
+ }
+ }
+ }
+
+ fun dismissClubNudge() {
+ clubNudge = null
+ }
+
+ /** Switches to the offered match. */
+ fun acceptClubNudge(): Boolean {
+ val nudge = clubNudge ?: return false
+ clubNudge = null
+ play(nudge.channel)
+ return true
+ }
+
fun refreshActive() {
when (sourceMode) {
SourceMode.GENERIC -> source.refresh()
@@ -257,6 +382,9 @@ class AppState(
/** How far ahead a kick-off counts as "about to start". */
const val SOON_WINDOW_MS = 30 * 60 * 1000L
+
+ /** How long after kick-off the nudge is still worth showing. */
+ const val NUDGE_GRACE_MS = 5 * 60 * 1000L
}
fun isOnline(): Boolean {
@@ -276,7 +404,10 @@ class AppState(
}
fun play(channel: Channel) {
+ currentChannel?.takeIf { it.url != channel.url }?.let { previousChannel = it }
lastWatched = channel
+ lastWatchedUrl = channel.url
+ prefs.edit().putString("last_watched", channel.url).apply()
currentChannel = channel
playerVisible = true
// Re-opening the very channel that was just closed can hit the
@@ -297,6 +428,7 @@ class AppState(
}
fun stopPlayback() {
+ currentChannel?.let { lastWatched = it }
restorePending = lastWatched != null
reentryHandler.removeCallbacksAndMessages(null)
lastStoppedUrl = currentChannel?.url.orEmpty()
@@ -311,6 +443,19 @@ class AppState(
player.retryNow()
}
+ /**
+ * Back to the channel watched before this one — the button every remote
+ * has and this app did not. Falls back to the last channel of a finished
+ * session, so it also works right after the app starts.
+ */
+ fun zapBack() {
+ val target = previousChannel
+ ?: lastWatched?.takeIf { it.url != currentChannel?.url }
+ ?: return
+ val known = activeChannels().firstOrNull { it.url == target.url } ?: target
+ play(known)
+ }
+
fun zap(direction: Int) {
val list = activeChannels()
if (list.isEmpty()) return
diff --git a/app/src/main/java/dev/castarr/tv/MainActivity.kt b/app/src/main/java/dev/castarr/tv/MainActivity.kt
index c54cc35..fa53b0b 100644
--- a/app/src/main/java/dev/castarr/tv/MainActivity.kt
+++ b/app/src/main/java/dev/castarr/tv/MainActivity.kt
@@ -145,8 +145,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("next", info.next?.title ?: "")
)
}
+ // The phone marks a channel by backend id where there is one, and by
+ // its position in the list where there is not (plain M3U).
val favorites = org.json.JSONArray()
- state.dispatcharr.favorites.value.forEach { favorites.put(it) }
+ channels.forEachIndexed { index, channel ->
+ if (state.isFavorite(channel)) {
+ favorites.put(if (channel.backendId != 0) channel.backendId else -(index + 1))
+ }
+ }
// The club menus exist on the TV; the phone should offer them too.
val teams = org.json.JSONArray()
dev.castarr.tv.data.TeamFilters.all
@@ -168,7 +174,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
teams.put(
JSONObject()
.put("key", club.key)
- .put("label", club.label)
+ .put("label", club.shortName)
.put("name", club.fullName)
.put("entries", entries)
)
@@ -177,7 +183,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
.put("nowNext", nowNext)
.put("favorites", favorites)
.put("teams", teams)
- .put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
+ .put("favoritesSupported", true)
}
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
@@ -224,8 +230,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
}
override fun onToggleFavorite(channelId: Int) {
- val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
- state.dispatcharr.toggleFavorite(channel)
+ val channels = state.activeChannels()
+ val channel = if (channelId < 0) {
+ channels.getOrNull(-channelId - 1)
+ } else {
+ channels.firstOrNull { it.backendId == channelId }
+ } ?: return
+ state.toggleFavorite(channel)
+ // The backend round-trip needs a moment; a local favourite does not,
+ // but one delayed broadcast covers both.
mainHandler.postDelayed({ server.broadcastChannels() }, 800)
}
@@ -243,6 +256,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
onDigit(keyCode - KeyEvent.KEYCODE_0)
return true
}
+ // Channel keys jump the open list by initial letter.
+ if (!state.playerVisible && state.screen == AppState.Screen.LIVE) {
+ when (keyCode) {
+ KeyEvent.KEYCODE_CHANNEL_UP -> {
+ state.requestLetterJump(-1)
+ return true
+ }
+ KeyEvent.KEYCODE_CHANNEL_DOWN -> {
+ state.requestLetterJump(1)
+ return true
+ }
+ else -> Unit
+ }
+ }
if (state.playerVisible) {
when (keyCode) {
// OK opens the control overlay (buttons take over from
@@ -251,6 +278,9 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
// reaches the activity.
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER -> {
+ // An offered club match takes the key before playback
+ // controls do — that is what the hint on it promises.
+ if (state.acceptClubNudge()) return true
state.pingOverlay()
return true
}
@@ -270,6 +300,13 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
state.stopPlayback()
return true
}
+ // The jump every remote has: back to what was on before.
+ KeyEvent.KEYCODE_LAST_CHANNEL,
+ KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
+ state.pingOverlay()
+ state.zapBack()
+ return true
+ }
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_CHANNEL_UP -> {
state.zap(-1)
@@ -296,6 +333,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
return true
}
KeyEvent.KEYCODE_BACK -> {
+ if (state.clubNudge != null) {
+ state.dismissClubNudge()
+ return true
+ }
if (state.overlayVisible) state.overlayVisible = false
else state.stopPlayback()
return true
diff --git a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
index 02a8278..1871548 100644
--- a/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
+++ b/app/src/main/java/dev/castarr/tv/data/DispatcharrRepository.kt
@@ -34,6 +34,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
val epgUpdatedAt = MutableStateFlow(0L)
val status = MutableStateFlow("")
+ /** When the channel list last came through, persisted across restarts. */
+ val updatedAt = MutableStateFlow(prefs.getLong("channels_at", 0L))
+
+ /** True while the most recent attempt to reach the backend failed. */
+ val unreachable = MutableStateFlow(false)
+
private var programmesByTvgId: Map> = emptyMap()
var outputProfile: String = ""
@@ -70,6 +76,11 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
list.size
}
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
+ result.onSuccess {
+ updatedAt.value = System.currentTimeMillis()
+ prefs.edit().putLong("channels_at", updatedAt.value).apply()
+ }
+ unreachable.value = result.isFailure
status.value = if (result.isSuccess) "" else "channels_error"
onDone(result)
}
diff --git a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
index b3dc1d7..8675db5 100644
--- a/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
+++ b/app/src/main/java/dev/castarr/tv/data/SourceRepository.kt
@@ -28,6 +28,12 @@ class SourceRepository(context: Context) {
val epgUpdatedAt = MutableStateFlow(0L)
val status = MutableStateFlow("")
+ /** When the channel list last came through, persisted across restarts. */
+ val updatedAt = MutableStateFlow(prefs.getLong("channels_at", 0L))
+
+ /** True while the most recent attempt to reach the source failed. */
+ val unreachable = MutableStateFlow(false)
+
private var programmes: Map> = emptyMap()
private var nameToEpgId: Map = emptyMap()
@@ -62,6 +68,11 @@ class SourceRepository(context: Context) {
parsed.size
}
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
+ result.onSuccess {
+ updatedAt.value = System.currentTimeMillis()
+ prefs.edit().putLong("channels_at", updatedAt.value).apply()
+ }
+ unreachable.value = result.isFailure
status.value = if (result.isSuccess) "" else "channels_error"
onDone(result)
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
diff --git a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
index cfcbaef..74d09ee 100644
--- a/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
+++ b/app/src/main/java/dev/castarr/tv/data/TeamFilters.kt
@@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.Color
*/
data class TeamFilter(
val key: String,
- /** Short caption next to the crest in the rail. */
+ /** Two-to-four letter code, shown next to the full name in the picker. */
val label: String,
+ /**
+ * Name for the club menu in the channel list. The code alone ("FCSP")
+ * is unreadable for anyone who did not set the club up themselves.
+ */
+ val shortName: String,
/** Full club name, shown in the settings and in messages. */
val fullName: String,
/** Lowercase needles matched against programme titles. */
@@ -60,75 +65,76 @@ object TeamFilters {
private fun club(
key: String,
label: String,
+ shortName: String,
fullName: String,
needles: List,
primary: Long,
secondary: Long,
article: String = fullName,
league: Int = 1,
- ) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article, league)
+ ) = TeamFilter(key, label, shortName, fullName, needles, Color(primary), Color(secondary), article, league)
/** Clubs of the top three German divisions. */
val all: List = listOf(
// --- Bundesliga ---
- club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
- club("bvb", "BVB", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
- club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
- club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
- club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
- club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
- club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
- club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
- club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
- club("freiburg", "SCF", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
- club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
- club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
- club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
- club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
- club("koeln", "EFC", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
- club("hsv", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
- club("heidenheim", "HDH", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
- club("st-pauli", "FCSP", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
+ club("bayern", "FCB", "Bayern", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
+ club("bvb", "BVB", "Dortmund", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
+ club("leipzig", "RBL", "Leipzig", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
+ club("leverkusen", "B04", "Leverkusen", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
+ club("frankfurt", "SGE", "Frankfurt", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
+ club("stuttgart", "1893", "Stuttgart", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
+ club("gladbach", "BMG", "Gladbach", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
+ club("wolfsburg", "WOB", "Wolfsburg", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
+ club("bremen", "SVW", "Werder", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
+ club("freiburg", "SCF", "Freiburg", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
+ club("hoffenheim", "TSG", "Hoffenheim", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
+ club("mainz", "M05", "Mainz", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
+ club("augsburg", "FCA", "Augsburg", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
+ club("union", "FCU", "Union Berlin", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
+ club("koeln", "KOE", "Köln", "1. FC Köln", listOf("1. fc köln", "fc köln"), 0xFFE1000F, WHITE, league = 1),
+ club("hsv", "HSV", "HSV", "Hamburger SV", listOf("hamburger sv", "hsv"), 0xFF0E5EA6, BLACK, league = 1),
+ club("heidenheim", "HDH", "Heidenheim", "1. FC Heidenheim", listOf("heidenheim"), 0xFFE1000F, 0xFF1656A4, league = 1),
+ club("st-pauli", "FCSP", "St. Pauli", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
// --- 2. Bundesliga ---
- club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
- club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
- club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
- club("nuernberg", "FCN", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
- club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
- club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
- club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
- club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
- club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
- club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
- club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
- club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
- club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
- club("muenster", "SCPM", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
- club("fuerth", "SGF", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
- club("holstein", "KSV", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
- club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
- club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
+ club("schalke", "S04", "Schalke", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
+ club("hertha", "BSC", "Hertha", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
+ club("duesseldorf", "F95", "Düsseldorf", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
+ club("nuernberg", "FCN", "Nürnberg", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
+ club("kaiserslautern", "FCK", "Kaiserslautern", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
+ club("karlsruhe", "KSC", "Karlsruhe", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
+ club("hannover", "H96", "Hannover 96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
+ club("paderborn", "SCP", "Paderborn", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
+ club("magdeburg", "FCM", "Magdeburg", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
+ club("elversberg", "SVE", "Elversberg", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
+ club("darmstadt", "SV98", "Darmstadt", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
+ club("braunschweig", "BTSV", "Braunschweig", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
+ club("bochum", "BOC", "Bochum", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
+ club("muenster", "SCPM", "Münster", "Preußen Münster", listOf("preußen münster", "münster"), 0xFF00703C, WHITE, league = 2),
+ club("fuerth", "SGF", "Fürth", "SpVgg Greuther Fürth", listOf("greuther fürth", "fürth"), 0xFF00A94F, WHITE, league = 2),
+ club("holstein", "KSV", "Kiel", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
+ club("dresden", "SGD", "Dresden", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
+ club("bielefeld", "DSC", "Bielefeld", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
// --- 3. Liga ---
- club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
- club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
- club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
- club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
- club("essen", "RWE", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
- club("duisburg", "MSV", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
- club("mannheim", "SVWM", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
- club("wehen", "SVWW", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
- club("ulm", "SSV", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
- club("regensburg", "SSVJ", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
- club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
- club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
- club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
- club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
- club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
- club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
- club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
- club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
- club("stuttgart-ii", "VfB2", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
- club("hoffenheim-ii", "TSG2", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
+ club("hansa", "FCH", "Rostock", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
+ club("saarbruecken", "FCS", "Saarbrücken", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
+ club("aue", "FCE", "Aue", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
+ club("cottbus", "FCEC", "Cottbus", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
+ club("essen", "RWE", "Essen", "Rot-Weiss Essen", listOf("rot-weiss essen", "rot weiss essen"), 0xFFE1000F, WHITE, league = 3),
+ club("duisburg", "MSV", "Duisburg", "MSV Duisburg", listOf("msv duisburg", "duisburg"), 0xFF004E9E, WHITE, league = 3),
+ club("mannheim", "SVWM", "Mannheim", "SV Waldhof Mannheim", listOf("waldhof mannheim", "waldhof"), 0xFF0B4EA2, WHITE, league = 3),
+ club("wehen", "SVWW", "Wehen Wiesbaden", "SV Wehen Wiesbaden", listOf("wehen wiesbaden", "wehen"), 0xFFE1000F, BLACK, league = 3),
+ club("ulm", "SSV", "Ulm", "SSV Ulm 1846", listOf("ssv ulm"), 0xFFE1000F, WHITE, article = "SSV Ulm 1846 Fußball", league = 3),
+ club("regensburg", "SSVJ", "Regensburg", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
+ club("verl", "SCV", "Verl", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
+ club("viktoria", "VKÖ", "Viktoria Köln", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
+ club("havelse", "TSVH", "Havelse", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
+ club("schweinfurt", "FC05", "Schweinfurt", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
+ club("osnabrueck", "VfLO", "Osnabrück", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
+ club("aachen", "ALE", "Aachen", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
+ club("ingolstadt", "FCI", "Ingolstadt", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
+ club("wuppertal", "WSV", "Wuppertal", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
+ club("stuttgart-ii", "VfB2", "Stuttgart II", "VfB Stuttgart II", listOf("vfb stuttgart ii"), 0xFFE32219, BLACK, league = 3),
+ club("hoffenheim-ii", "TSG2", "Hoffenheim II", "TSG Hoffenheim II", listOf("hoffenheim ii"), 0xFF1C63B7, BLACK, article = "TSG 1899 Hoffenheim", league = 3),
)
/** Clubs switched on for a viewer before they touch the settings. */
@@ -156,6 +162,30 @@ object TeamFilters {
return groupIncludes.any { g.contains(it) }
}
+ /**
+ * The fixture inside an EPG title: "Pokal: Nordstadt - FC St. Pauli,
+ * 1. Runde" becomes "Nordstadt - FC St. Pauli".
+ *
+ * In the club menu the row exists *because* of that pairing, and the
+ * club is usually the second half — exactly the half a one-line title
+ * cuts off. Returns null when there is no pairing to find, and the
+ * caller keeps the original title.
+ */
+ fun fixture(title: String): String? {
+ // Drop a leading competition ("Pokal:", "Bundesliga:").
+ val body = title.substringAfter(":", title).trim()
+ val separator = SEPARATORS.firstOrNull { body.contains(it) } ?: return null
+ // Trailing detail after the pairing ("…, 1. Runde", "… | live").
+ val pairing = body.substringBefore(",").substringBefore(" | ").trim()
+ if (!pairing.contains(separator)) return null
+ val (home, away) = pairing.split(separator, limit = 2)
+ if (home.isBlank() || away.isBlank()) return null
+ return "${home.trim()} - ${away.trim()}"
+ }
+
+ /** Dash variants German EPG data uses between the two teams. */
+ private val SEPARATORS = listOf(" - ", " – ", " — ", " vs. ", " vs ", " gegen ")
+
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
/** Clubs grouped by division, in the order the picker shows them. */
diff --git a/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt b/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt
index b19dfef..56004ce 100644
--- a/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt
+++ b/app/src/main/java/dev/castarr/tv/pairing/Pairing.kt
@@ -71,6 +71,12 @@ object Pairing {
prefs(context).edit().clear().apply()
}
+ /**
+ * How many phones would have to scan again after a reset — the number
+ * the confirmation asks about, so nobody wipes a working pairing blind.
+ */
+ fun pairedCount(context: Context): Int = sessionTokens(context).size
+
private fun sessionTokens(context: Context): List =
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
diff --git a/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt b/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
index 4a5bf6d..4c1a441 100644
--- a/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/AdvancedScreen.kt
@@ -54,19 +54,22 @@ fun AdvancedScreen(state: AppState) {
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
Spacer(Modifier.height(18.dp))
+ // Labels sit above the field, not in a notch in its outline: the
+ // notch never cut out cleanly here and the border ran straight
+ // through the text.
+ FieldLabel("M3U-URL")
TvTextField(
value = m3u,
onValueChange = { m3u = it },
- label = { Text("M3U-URL", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
)
- Spacer(Modifier.height(12.dp))
+ Spacer(Modifier.height(14.dp))
+ FieldLabel("XMLTV-EPG-URL (optional)")
TvTextField(
value = epg,
onValueChange = { epg = it },
- label = { Text("XMLTV-EPG-URL (optional)", fontFamily = AppFont) },
colors = fieldColors,
textStyle = TextStyle(fontSize = 14.sp),
modifier = Modifier.width(600.dp),
@@ -96,3 +99,15 @@ fun AdvancedScreen(state: AppState) {
}
}
}
+
+@Composable
+private fun FieldLabel(text: String) {
+ Text(
+ text.uppercase(),
+ color = CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 11.sp,
+ letterSpacing = 1.5.sp,
+ modifier = Modifier.padding(bottom = 6.dp, start = 2.dp),
+ )
+}
diff --git a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
index f4e069c..3e2b892 100644
--- a/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/CastarrApp.kt
@@ -1,21 +1,32 @@
package dev.castarr.tv.ui
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
-import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
@@ -24,11 +35,10 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.tv.material3.Surface
+import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.LocalContentColor
+import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import dev.castarr.tv.AppState
import kotlin.math.PI
@@ -69,6 +79,7 @@ fun CastarrApp(state: AppState) {
if (state.digitBuffer.isNotEmpty()) {
DigitBadge(state)
}
+ ClubNudge(state)
}
}
@@ -118,6 +129,8 @@ private fun TopBar(state: AppState) {
fontWeight = FontWeight.Medium,
letterSpacing = 4.sp,
)
+ Spacer(Modifier.width(24.dp))
+ StaleNotice(state)
Spacer(Modifier.weight(1f))
state.connectedRemote?.let { name ->
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -215,3 +228,120 @@ private fun GearButton(onClick: () -> Unit) {
}
}
}
+
+/**
+ * Says so when the source could not be reached. Without it a cached channel
+ * list looks exactly like a normal evening with an empty EPG — the club
+ * menus even count down to zero.
+ */
+@Composable
+private fun StaleNotice(state: AppState) {
+ val unreachable by when (state.sourceMode) {
+ AppState.SourceMode.GENERIC -> state.source.unreachable
+ AppState.SourceMode.DISPATCHARR -> state.dispatcharr.unreachable
+ }.collectAsState()
+ val updatedAt by when (state.sourceMode) {
+ AppState.SourceMode.GENERIC -> state.source.updatedAt
+ AppState.SourceMode.DISPATCHARR -> state.dispatcharr.updatedAt
+ }.collectAsState()
+ if (!unreachable) return
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(7.dp)
+ .clip(CircleShape)
+ .background(CastarrColors.live)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ if (updatedAt > 0) {
+ "Server nicht erreichbar · Stand ${formatClock(updatedAt)}"
+ } else {
+ "Server nicht erreichbar"
+ },
+ color = CastarrColors.muted,
+ fontFamily = AppFont,
+ fontSize = 13.sp,
+ )
+ }
+}
+
+/**
+ * "St. Pauli läuft jetzt auf Liga Live UHD." Shown over the running picture
+ * when a club match starts elsewhere; OK switches, Zurück dismisses.
+ */
+@Composable
+private fun ClubNudge(state: AppState) {
+ LaunchedEffect(state.playerVisible) {
+ while (state.playerVisible) {
+ state.checkClubNudge()
+ kotlinx.coroutines.delay(30_000)
+ }
+ }
+ val nudge = state.clubNudge ?: return
+ // The card takes focus while it is up: with the playback overlay open,
+ // one of its buttons would otherwise swallow the OK the card promises.
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(nudge) { runCatching { focus.requestFocus() } }
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
+ Surface(
+ onClick = { state.acceptClubNudge() },
+ modifier = Modifier
+ .padding(top = 28.dp, end = 40.dp)
+ .focusRequester(focus),
+ shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(14.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = CastarrColors.surface,
+ contentColor = CastarrColors.fg,
+ focusedContainerColor = CastarrColors.surface,
+ focusedContentColor = CastarrColors.fg,
+ ),
+ border = ClickableSurfaceDefaults.border(
+ border = Border(
+ border = BorderStroke(1.dp, CastarrColors.accent),
+ shape = RoundedCornerShape(14.dp),
+ ),
+ focusedBorder = Border(
+ border = BorderStroke(2.dp, CastarrColors.accent),
+ shape = RoundedCornerShape(14.dp),
+ ),
+ ),
+ ) {
+ Column(
+ Modifier.padding(horizontal = 20.dp, vertical = 14.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(7.dp)
+ .clip(CircleShape)
+ .background(CastarrColors.live)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ "${nudge.clubName} läuft jetzt",
+ color = CastarrColors.fg,
+ fontFamily = AppFont,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ nudge.channel.name,
+ color = CastarrColors.muted,
+ fontFamily = AppFont,
+ fontSize = 13.sp,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "OK zum Wechseln · Zurück zum Ausblenden",
+ color = CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 12.sp,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/castarr/tv/ui/DayPlan.kt b/app/src/main/java/dev/castarr/tv/ui/DayPlan.kt
new file mode 100644
index 0000000..94cff9c
--- /dev/null
+++ b/app/src/main/java/dev/castarr/tv/ui/DayPlan.kt
@@ -0,0 +1,150 @@
+package dev.castarr.tv.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.tv.material3.Text
+import dev.castarr.tv.AppState
+import dev.castarr.tv.data.isEpgPlaceholder
+import dev.castarr.tv.playlist.Channel
+
+/**
+ * What this channel still shows today. The EPG beyond "now and next" the
+ * app was missing: the data was parsed all along, only never displayed.
+ *
+ * Read-only on purpose — no recordings, no reminders, nothing to operate.
+ */
+@Composable
+internal fun DayPlanDialog(state: AppState, channel: Channel, onClose: () -> Unit) {
+ val programmes = remember(channel.url) {
+ state.upcomingToday(channel).filterNot { isEpgPlaceholder(it.title) }
+ }
+ Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ Modifier
+ .width(760.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(CastarrColors.surface)
+ .padding(horizontal = 26.dp, vertical = 22.dp)
+ ) {
+ Text(
+ channel.name,
+ color = CastarrColors.fg,
+ fontFamily = AppFont,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ "Heute noch",
+ color = CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 12.sp,
+ letterSpacing = 1.5.sp,
+ )
+ Spacer(Modifier.height(14.dp))
+ if (programmes.isEmpty()) {
+ Text(
+ "Für diesen Sender liegt kein Programm vor.",
+ color = CastarrColors.muted,
+ fontFamily = AppFont,
+ fontSize = 14.sp,
+ )
+ } else {
+ val now = System.currentTimeMillis()
+ LazyColumn(
+ Modifier.heightIn(max = 520.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ itemsIndexed(programmes) { index, programme ->
+ val running = now in programme.start until programme.stop
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ if (running) CastarrColors.accentDim
+ else androidx.compose.ui.graphics.Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 9.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ formatClock(programme.start),
+ color = if (running) CastarrColors.accent
+ else CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 13.sp,
+ modifier = Modifier.width(64.dp),
+ )
+ Text(
+ programme.title,
+ color = if (running) CastarrColors.accent
+ else CastarrColors.fg,
+ fontFamily = AppFont,
+ fontSize = 15.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ if (running) {
+ Text(
+ "läuft",
+ color = CastarrColors.accent,
+ fontFamily = AppFont,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ if (index < programmes.lastIndex) {
+ Box(
+ Modifier
+ .padding(horizontal = 12.dp)
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(CastarrColors.line)
+ )
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(16.dp))
+ Text(
+ "Zurück zum Schließen",
+ color = CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 12.sp,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt b/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
index 2964eba..97806b7 100644
--- a/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/LiveCells.kt
@@ -153,10 +153,13 @@ private fun LogoInitials(initials: String) {
internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
val programme = hit.programme
val running = System.currentTimeMillis() in programme.start until programme.stop
+ // The row is here because of the fixture, so the fixture is what the
+ // line shows — the raw title cut off exactly at the club's name.
+ val title = dev.castarr.tv.data.TeamFilters.fixture(programme.title) ?: programme.title
Column(modifier) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
- programme.title,
+ title,
color = CastarrColors.fg,
fontFamily = AppFont,
fontSize = 14.sp,
@@ -189,9 +192,24 @@ internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier)
* under the title, next programme only when it differs (design review P1/P2).
*/
@Composable
-internal fun EpgCell(nowNext: NowNext, modifier: Modifier = Modifier) {
+internal fun EpgCell(
+ nowNext: NowNext,
+ modifier: Modifier = Modifier,
+ /** Shown instead of an empty half-row when the channel has no EPG. */
+ fallback: String = "",
+) {
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
Column(modifier) {
+ if (now == null && fallback.isNotEmpty()) {
+ Text(
+ fallback,
+ color = CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 13.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
if (now != null) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt b/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
index d276de7..ae955db 100644
--- a/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/LiveChannelRow.kt
@@ -101,7 +101,7 @@ internal fun ChannelRow(
// whatever happens to be running.
HighlightCell(highlight, Modifier.weight(1f))
} else {
- EpgCell(nowNext, Modifier.weight(1f))
+ EpgCell(nowNext, Modifier.weight(1f), fallback = channel.group)
}
if (playing) {
Spacer(Modifier.width(12.dp))
diff --git a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
index 26ecc89..a97d8a8 100644
--- a/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/LiveScreen.kt
@@ -13,28 +13,28 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
-import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -85,7 +85,7 @@ fun LiveScreen(state: AppState) {
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
val channels = when {
activeTeam != null -> activeMatches.map { it.first }
- state.favoritesOnly -> allChannels.filter { it.backendId in favorites }
+ state.favoritesOnly -> allChannels.filter { state.isFavorite(it) }
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
else -> allChannels
}
@@ -110,11 +110,46 @@ fun LiveScreen(state: AppState) {
val restoreFocus = remember { FocusRequester() }
val listState = rememberLazyListState()
// Index of the channel the viewer last watched, so leaving playback
- // returns them to where they were instead of the top of the list.
- val restoreIndex = state.lastWatched?.let { watched ->
- channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 }
+ // returns them to where they were instead of the top of the list. The
+ // URL survives a restart, so switching the TV on lands there too.
+ val restoreTarget = state.restoreTargetUrl()
+ val restoreIndex = restoreTarget.takeIf { it.isNotEmpty() }?.let { url ->
+ channels.indexOfFirst { it.url == url }.takeIf { it >= 0 }
}
var restored by remember { mutableStateOf(false) }
+ // Right jumps to the next initial letter — the only way through a long
+ // list without a text field, which the ten-foot rule rules out.
+ var focusedIndex by remember { mutableStateOf(0) }
+ var jumpIndex by remember { mutableStateOf(null) }
+ val jumpFocus = remember { FocusRequester() }
+ LaunchedEffect(jumpIndex) {
+ val target = jumpIndex ?: return@LaunchedEffect
+ listState.scrollToItem(target)
+ runCatching { jumpFocus.requestFocus() }
+ focusedIndex = target
+ jumpIndex = null
+ }
+ fun initial(name: String) = name.trimStart().firstOrNull()?.uppercaseChar()
+ fun jumpToInitial(direction: Int) {
+ if (channels.isEmpty()) return
+ val from = focusedIndex.coerceIn(0, channels.lastIndex)
+ val current = initial(channels[from].name)
+ jumpIndex = if (direction > 0) {
+ val next = channels.drop(from + 1).indexOfFirst { initial(it.name) != current }
+ // Past the last letter, wrap to the top rather than do nothing.
+ if (next >= 0) from + 1 + next else 0
+ } else {
+ val before = channels.take(from).indexOfLast { initial(it.name) != current }
+ if (before < 0) 0 else {
+ val letter = initial(channels[before].name)
+ channels.take(before + 1).indexOfFirst { initial(it.name) == letter }
+ }
+ }
+ }
+ LaunchedEffect(state.letterJump) {
+ if (state.letterJump > 0) jumpToInitial(state.letterJumpDirection)
+ }
+ var dayPlanFor by remember { mutableStateOf(null) }
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
// A fresh group starts at the top; a return from playback does not.
if (restoreIndex == null) listState.scrollToItem(0)
@@ -125,6 +160,10 @@ fun LiveScreen(state: AppState) {
runCatching { restoreFocus.requestFocus() }
restored = true
state.restorePending = false
+ } else if (restoreIndex == null && !restored && channels.isNotEmpty()) {
+ // Nothing to restore, but the list still beats the settings gear
+ // as a starting point.
+ restored = runCatching { listFocus.requestFocus() }.isSuccess
}
}
// A FocusRequester that is not currently attached throws when used, and
@@ -164,21 +203,21 @@ fun LiveScreen(state: AppState) {
state.groupFilter = null
}
}
- if (isDispatcharr) {
- item {
- GroupItem(
- label = "★ Favoriten",
- count = favorites.size,
- selected = state.favoritesOnly && state.activeTeam == null,
- modifier = intoList.then(
- if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
- ),
- suppressAutoSelect = { state.restorePending },
- ) {
- state.activeTeam = null
- state.favoritesOnly = true
- state.groupFilter = null
- }
+ // Favourites exist for both source types now, so the entry is
+ // no longer tied to being signed in.
+ item {
+ GroupItem(
+ label = "★ Favoriten",
+ count = state.favoriteCount(),
+ selected = state.favoritesOnly && state.activeTeam == null,
+ modifier = intoList.then(
+ if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
+ ),
+ suppressAutoSelect = { state.restorePending },
+ ) {
+ state.activeTeam = null
+ state.favoritesOnly = true
+ state.groupFilter = null
}
}
items(teams, key = { it.key }) { club ->
@@ -187,7 +226,7 @@ fun LiveScreen(state: AppState) {
state.teamSignal(clubMatches)
}
GroupItem(
- label = club.label,
+ label = club.shortName,
count = clubMatches.size,
selected = state.activeTeam == club.key,
leading = { Crest(club, state, clubSignal.urgency) },
@@ -263,31 +302,48 @@ fun LiveScreen(state: AppState) {
// matching the remote's number pad in every view.
number = allChannels.indexOf(channel) + 1,
modifier = Modifier
+ .onFocusChanged { if (it.isFocused) focusedIndex = listIndex }
.onPreviewKeyEvent { event ->
- event.type == KeyEventType.KeyDown &&
- event.key == Key.DirectionLeft &&
- runCatching { railFocus.requestFocus() }.isSuccess
+ if (event.type != KeyEventType.KeyDown) {
+ return@onPreviewKeyEvent false
+ }
+ when (event.key) {
+ Key.DirectionLeft ->
+ runCatching { railFocus.requestFocus() }.isSuccess
+ // The day plan for this channel — the EPG
+ // beyond "now and next" (#16).
+ Key.DirectionRight -> {
+ dayPlanFor = channel
+ true
+ }
+ else -> false
+ }
}
.then(if (listIndex == 0) Modifier.focusRequester(listFocus) else Modifier)
.then(
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
else Modifier
+ )
+ .then(
+ if (listIndex == jumpIndex) Modifier.focusRequester(jumpFocus)
+ else Modifier
),
nowNext = state.nowNext(channel),
highlight = teamHits[channel.url],
playing = state.currentChannel?.url == channel.url,
// All rows are favorites in the favorites view — the
// star only carries meaning elsewhere.
- favorite = isDispatcharr && !state.favoritesOnly &&
- channel.backendId in favorites,
+ favorite = !state.favoritesOnly && state.isFavorite(channel),
epgStamp = epgStamp,
- onLongClick = if (isDispatcharr) {
- { state.dispatcharr.toggleFavorite(channel) }
- } else null,
+ onLongClick = { state.toggleFavorite(channel) },
) { state.play(channel) }
}
}
}
+
+ dayPlanFor?.let { channel ->
+ DayPlanDialog(state, channel) { dayPlanFor = null }
+ }
}
@Composable
@@ -357,7 +413,7 @@ private fun GroupItem(
) {
Text(
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
- else formatClock(signal!!.kickOff),
+ else "ab ${formatClock(signal!!.kickOff)}",
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
else CastarrColors.accent,
fontFamily = AppFont,
diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
index 1cca46f..d258dd8 100644
--- a/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/SettingsClubs.kt
@@ -15,11 +15,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -27,6 +29,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -91,6 +98,35 @@ internal fun ClubPickerDialog(
)
}
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
+ val listState = rememberLazyListState()
+ // Left and right jump a whole division: without it the third league is
+ // forty presses away.
+ var leagueIndex by remember { mutableStateOf(0) }
+ val leagueFocus = remember { FocusRequester() }
+ var leagueJump by remember { mutableStateOf(false) }
+ // Item index of each division header in the flat list the LazyColumn
+ // builds (one header plus its clubs per section).
+ val leagueStarts = remember(sections) {
+ var index = 0
+ sections.map { (_, clubs) ->
+ val start = index
+ index += clubs.size + 1
+ start
+ }
+ }
+ val leagueFirstKeys = remember(sections) { sections.map { it.second.firstOrNull()?.key } }
+ LaunchedEffect(leagueIndex, leagueJump) {
+ if (!leagueJump) return@LaunchedEffect
+ leagueStarts.getOrNull(leagueIndex)?.let { listState.scrollToItem(it) }
+ runCatching { leagueFocus.requestFocus() }
+ leagueJump = false
+ }
+ fun jumpLeague(direction: Int) {
+ val next = (leagueIndex + direction).coerceIn(0, sections.lastIndex.coerceAtLeast(0))
+ if (next == leagueIndex) return
+ leagueIndex = next
+ leagueJump = true
+ }
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Box(
Modifier
@@ -112,9 +148,28 @@ internal fun ClubPickerDialog(
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
- modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
+ modifier = Modifier.padding(start = 14.dp, bottom = 2.dp),
)
- LazyColumn(Modifier.heightIn(max = 460.dp)) {
+ Text(
+ "◀ ▶ Liga wechseln",
+ color = CastarrColors.faint,
+ fontFamily = AppFont,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(start = 14.dp, bottom = 8.dp),
+ )
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .heightIn(max = 460.dp)
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (event.key) {
+ Key.DirectionRight -> { jumpLeague(1); true }
+ Key.DirectionLeft -> { jumpLeague(-1); true }
+ else -> false
+ }
+ },
+ ) {
sections.forEach { (league, clubs) ->
item(key = "h-$league") {
Text(
@@ -128,9 +183,12 @@ internal fun ClubPickerDialog(
)
}
items(clubs, key = { it.key }) { club ->
- val focusMod =
- if (club.key == firstKey) Modifier.focusRequester(firstFocus)
- else Modifier
+ val focusMod = when (club.key) {
+ firstKey -> Modifier.focusRequester(firstFocus)
+ leagueFirstKeys.getOrNull(leagueIndex) ->
+ Modifier.focusRequester(leagueFocus)
+ else -> Modifier
+ }
Surface(
onClick = { onPick(club) },
modifier = Modifier
diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
index d310f26..f6ab5b9 100644
--- a/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/SettingsScreen.kt
@@ -1,8 +1,6 @@
package dev.castarr.tv.ui
import androidx.compose.foundation.background
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,9 +12,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,6 +27,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -61,18 +64,23 @@ fun SettingsScreen(state: AppState) {
var clubPicker by remember { mutableStateOf(false) }
// Bumped on reset so the QR code and the four-digit code redraw.
var pairingEpoch by remember { mutableStateOf(0) }
+ var confirmPairingReset by remember { mutableStateOf(false) }
+ // Focus belongs on the first row of the left column, not on the gear the
+ // viewer just left: from there "down" used to land on the destructive
+ // action in the right column and go nowhere after that.
+ val firstRowFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { runCatching { firstRowFocus.requestFocus() } }
Row(
Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 12.dp)
) {
- // The cards outgrew one screen once club menus arrived; focus
- // movement scrolls this column along.
+ // Three columns instead of two: the cards outgrew one screen once
+ // club menus arrived, and the ten-foot rule (CONTEXT.md) allows no
+ // scrolling outside the channel list.
Column(
- Modifier
- .weight(1.25f)
- .verticalScroll(rememberScrollState()),
+ Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingsCard("Konto") {
@@ -108,14 +116,21 @@ fun SettingsScreen(state: AppState) {
)
}
}
- SettingRow("Abmelden", danger = true) { state.startOnboarding() }
+ SettingRow(
+ "Abmelden",
+ danger = true,
+ modifier = Modifier.focusRequester(firstRowFocus),
+ ) { state.startOnboarding() }
} else {
Text(
"Nicht angemeldet.",
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
- SettingRow("Jetzt anmelden") { state.startOnboarding() }
+ SettingRow(
+ "Jetzt anmelden",
+ modifier = Modifier.focusRequester(firstRowFocus),
+ ) { state.startOnboarding() }
}
}
@@ -170,7 +185,7 @@ fun SettingsScreen(state: AppState) {
active.forEach { club ->
SettingRow(
club.fullName,
- subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
+ subtitle = "Erscheint als ${club.shortName} in der Senderliste · Entfernen",
leading = { ClubCrest(club, state) },
trailing = { RemoveIcon() },
) { state.toggleTeam(club.key) }
@@ -183,6 +198,14 @@ fun SettingsScreen(state: AppState) {
}
}
+ }
+
+ Spacer(Modifier.width(16.dp))
+
+ Column(
+ Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
SettingsCard("App") {
SettingRow(
"Version ${BuildConfig.VERSION_NAME}",
@@ -236,7 +259,7 @@ fun SettingsScreen(state: AppState) {
Spacer(Modifier.width(16.dp))
- Column(Modifier.weight(0.75f)) {
+ Column(Modifier.weight(1f)) {
SettingsCard("Handy-Fernbedienung") {
val address = remember(pairingEpoch) { Pairing.lanAddress() }
if (!state.remoteAvailable) {
@@ -273,10 +296,7 @@ fun SettingsScreen(state: AppState) {
SettingRow(
"Kopplung zurücksetzen",
subtitle = "Neuer Code, alle Handys müssen neu scannen",
- ) {
- Pairing.reset(context)
- pairingEpoch++
- }
+ ) { confirmPairingReset = true }
} else {
Text(
"Keine Netzwerkverbindung",
@@ -291,6 +311,26 @@ fun SettingsScreen(state: AppState) {
picker?.let { current ->
PickerDialog(current) { picker = null }
}
+ if (confirmPairingReset) {
+ val paired = remember(pairingEpoch) { Pairing.pairedCount(context) }
+ ConfirmDialog(
+ title = "Kopplung zurücksetzen?",
+ message = when (paired) {
+ 0 -> "Der Code und der QR-Code werden neu erzeugt. Bereits " +
+ "gekoppelte Handys müssen danach erneut scannen."
+ 1 -> "Ein gekoppeltes Handy verliert den Zugriff und muss " +
+ "erneut scannen."
+ else -> "$paired gekoppelte Handys verlieren den Zugriff und " +
+ "müssen erneut scannen."
+ },
+ confirmLabel = "Zurücksetzen",
+ onConfirm = {
+ Pairing.reset(context)
+ pairingEpoch++
+ },
+ onClose = { confirmPairingReset = false },
+ )
+ }
if (clubPicker) {
ClubPickerDialog(
state = state,
diff --git a/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt b/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
index 7b1060d..4451fd9 100644
--- a/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/SettingsWidgets.kt
@@ -239,11 +239,12 @@ internal fun SettingRow(
danger: Boolean = false,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
+ modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
- modifier = Modifier.fillMaxWidth(),
+ modifier = modifier.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(rowShape),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
colors = ClickableSurfaceDefaults.colors(
@@ -282,7 +283,7 @@ internal fun SettingRow(
color = CastarrColors.faint,
fontFamily = AppFont,
fontSize = 11.sp,
- maxLines = 1,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
@@ -296,9 +297,15 @@ internal fun SettingRow(
}
@Composable
-fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
+fun ActionButton(
+ label: String,
+ danger: Boolean = false,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
Surface(
onClick = onClick,
+ modifier = modifier,
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
@@ -341,3 +348,64 @@ fun QrCard(bitmap: Bitmap) {
}
}
}
+
+/**
+ * Asks before something irreversible happens. Focus starts on "Abbrechen":
+ * a stray press of OK must not be the one that carries out the action.
+ */
+@Composable
+internal fun ConfirmDialog(
+ title: String,
+ message: String,
+ confirmLabel: String,
+ onConfirm: () -> Unit,
+ onClose: () -> Unit,
+) {
+ val cancelFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
+ Dialog(
+ onDismissRequest = onClose,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(CastarrColors.bgDeep.copy(alpha = 0.88f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ Modifier
+ .width(520.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(CastarrColors.surface)
+ .padding(horizontal = 26.dp, vertical = 24.dp)
+ ) {
+ Text(
+ title,
+ color = CastarrColors.fg,
+ fontFamily = AppFont,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ message,
+ color = CastarrColors.muted,
+ fontFamily = AppFont,
+ fontSize = 15.sp,
+ )
+ Spacer(Modifier.height(22.dp))
+ Row {
+ ActionButton("Abbrechen", modifier = Modifier.focusRequester(cancelFocus)) {
+ onClose()
+ }
+ Spacer(Modifier.width(12.dp))
+ ActionButton(confirmLabel, danger = true) {
+ onConfirm()
+ onClose()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt b/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
index d3a3fda..ea6d2f0 100644
--- a/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/TvTextField.kt
@@ -21,7 +21,6 @@ import androidx.compose.ui.text.TextStyle
fun TvTextField(
value: String,
onValueChange: (String) -> Unit,
- label: @Composable () -> Unit,
colors: TextFieldColors,
modifier: Modifier = Modifier,
textStyle: TextStyle = TextStyle.Default,
@@ -30,7 +29,6 @@ fun TvTextField(
OutlinedTextField(
value = value,
onValueChange = onValueChange,
- label = label,
singleLine = true,
textStyle = textStyle,
colors = colors,
diff --git a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
index fb8d53e..63d3a05 100644
--- a/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
+++ b/app/src/main/java/dev/castarr/tv/ui/WelcomeScreen.kt
@@ -89,7 +89,10 @@ fun WelcomeScreen(state: AppState) {
}
Spacer(Modifier.height(12.dp))
Text(
- Pairing.remoteUrl(address),
+ // Both fallbacks in one line: the address for a phone
+ // whose camera will not scan, and the code the remote
+ // asks for when the QR was not what opened it.
+ "${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}",
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
)
}
diff --git a/tests/demo/make-demo-data.py b/tests/demo/make-demo-data.py
index 90d5619..9483834 100755
--- a/tests/demo/make-demo-data.py
+++ b/tests/demo/make-demo-data.py
@@ -13,9 +13,17 @@ p = argparse.ArgumentParser()
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
p.add_argument("--kickoff-in", type=int, default=18,
help="minutes until the club match starts")
+p.add_argument("--now", default=None,
+ help="base time as YYYY-mm-ddTHH:MM:SS; defaults to this "
+ "machine's clock. An emulator restored from a snapshot "
+ "runs on the clock it was saved with, so pass its time "
+ "here or every programme lands in the wrong hour")
args = p.parse_args()
-now = datetime.datetime.now().astimezone()
+if args.now:
+ now = datetime.datetime.strptime(args.now, "%Y-%m-%dT%H:%M:%S").astimezone()
+else:
+ now = datetime.datetime.now().astimezone()
def fmt(dt):
diff --git a/tests/smoke.sh b/tests/smoke.sh
index 1489f85..e39fe97 100755
--- a/tests/smoke.sh
+++ b/tests/smoke.sh
@@ -56,7 +56,12 @@ step "Emulator starten"
"$HELPERS/emulator.sh" start | tee -a "$LOG"
step "Demo-Quelle bereitstellen"
-python3 "$ROOT/tests/demo/make-demo-data.py" >>"$LOG" 2>&1 || fail "Demo-Daten fehlgeschlagen"
+# The emulator restores its snapshot with the clock frozen at save time, so
+# the demo programme has to be built around *its* idea of now, not ours.
+EMU_NOW="$("$ADB" -s "$SERIAL" shell date +%Y-%m-%dT%H:%M:%S | tr -d '\r')"
+echo " Emulator-Uhr: $EMU_NOW" | tee -a "$LOG"
+python3 "$ROOT/tests/demo/make-demo-data.py" --now "$EMU_NOW" >>"$LOG" 2>&1 ||
+ fail "Demo-Daten fehlgeschlagen"
# The emulator reaches the host at 10.0.2.2, which is the address baked into
# the demo playlist.
# A leftover server from an earlier run happily answers on this port while
diff --git a/tests/unit/FixtureTest.kt b/tests/unit/FixtureTest.kt
new file mode 100644
index 0000000..0b70530
--- /dev/null
+++ b/tests/unit/FixtureTest.kt
@@ -0,0 +1,59 @@
+package dev.castarr.tv.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class FixtureTest {
+
+ @Test
+ fun `competition prefix and round suffix fall away`() {
+ assertEquals(
+ "Nordstadt - FC St. Pauli",
+ TeamFilters.fixture("Pokal: Nordstadt - FC St. Pauli, 1. Runde"),
+ )
+ }
+
+ @Test
+ fun `a bare pairing survives unchanged`() {
+ assertEquals(
+ "FC Bayern München - Borussia Dortmund",
+ TeamFilters.fixture("FC Bayern München - Borussia Dortmund"),
+ )
+ }
+
+ @Test
+ fun `en dash and vs count as separators`() {
+ assertEquals("Schalke - HSV", TeamFilters.fixture("2. Liga: Schalke – HSV"))
+ assertEquals("Kiel - Rostock", TeamFilters.fixture("Kiel vs. Rostock"))
+ }
+
+ @Test
+ fun `trailing channel detail is dropped`() {
+ assertEquals(
+ "Werder - Union Berlin",
+ TeamFilters.fixture("Bundesliga: Werder - Union Berlin | live"),
+ )
+ }
+
+ /** A magazine about the club is a hit, but has no pairing to shorten. */
+ @Test
+ fun `a title without a pairing keeps the original`() {
+ assertNull(TeamFilters.fixture("FC St. Pauli: Der Rückblick"))
+ assertNull(TeamFilters.fixture("Sportschau"))
+ }
+
+ @Test
+ fun `a dangling separator is not a pairing`() {
+ assertNull(TeamFilters.fixture("Pokal: - , 1. Runde"))
+ }
+
+ /** Hyphenated club names must not be mistaken for the separator. */
+ @Test
+ fun `hyphens inside a name do not split`() {
+ assertEquals(
+ "Rot-Weiss Essen - Preußen Münster",
+ TeamFilters.fixture("3. Liga: Rot-Weiss Essen - Preußen Münster"),
+ )
+ }
+}