Act on the screen-by-screen review: 17 findings
All checks were successful
Build TV app / build (push) Successful in 43s
All checks were successful
Build TV app / build (push) Successful in 43s
Defects. "Kopplung zurücksetzen" now asks first, naming how many phones would have to scan again — one stray OK used to wipe a working pairing, and the settings put the focus right on it: entering the screen landed on the gear, one press down jumped into the right column onto that very action, and every further press down did nothing. Focus now starts on the first row of the left column. 1. FC Köln was labelled "EFC". An unreachable source used to look like an evening without programmes — cached list, empty EPG, club menus counting down to zero — so both repositories now record when they last came through, and the top bar says "Server nicht erreichbar · Stand 14:46". The field labels on "Erweitert" sat on the outline with the border running through them; they sit above their field now. Polish. The app opens on the channel list — on the channel last watched, which survives a restart — instead of the settings gear. Rows without EPG fill their empty half with the channel's group. The club chip says "ab 15:06" like the row next to it, the club menu carries the club's name instead of "FCSP", and a fixture title is shortened to the fixture, so "Pokal: Nordstadt - FC St. P…" became "Nordstadt - FC St. Pauli". The settings are three columns and no longer scroll (CONTEXT.md's ten-foot rule). Features. Favourites work for a plain M3U source too, stored on the device and mirrored to the phone; they used to exist only with a Dispatcharr login. The channel keys jump the list by initial letter, left and right jump a division in the club picker, and right on a channel opens its day plan — the EPG beyond now/next, which was parsed all along and never shown. When a club match starts elsewhere the player offers to switch. The remote asked for a code "unten auf dem TV-Bildschirm" where none was; the welcome screen shows it now and the wording points at where it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,7 +168,7 @@
|
|||||||
<!-- Pairing -->
|
<!-- Pairing -->
|
||||||
<section class="view" id="view-pair">
|
<section class="view" id="view-pair">
|
||||||
<h1>Mit dem TV koppeln</h1>
|
<h1>Mit dem TV koppeln</h1>
|
||||||
<p>Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.</p>
|
<p>Gib den 4-stelligen Code ein, der auf dem Fernseher unter dem QR-Code steht.</p>
|
||||||
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
|
<input id="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="4" placeholder="····">
|
||||||
<button class="btn-accent" id="pair-btn">Verbinden</button>
|
<button class="btn-accent" id="pair-btn">Verbinden</button>
|
||||||
<div id="pair-error"></div>
|
<div id="pair-error"></div>
|
||||||
@@ -449,6 +449,9 @@
|
|||||||
}
|
}
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const favSet = new Set(state.extras.favorites || []);
|
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 favOn = state.extras.favoritesSupported && state.favOnly;
|
||||||
const teams = state.extras.teams || [];
|
const teams = state.extras.teams || [];
|
||||||
const club = teams.find((t) => t.key === state.teamKey);
|
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 clubHits = club ? new Map(club.entries.map((e) => [e.url, e])) : null;
|
||||||
const shown = club
|
const shown = club
|
||||||
? filtered.filter((c) => clubHits.has(c.url))
|
? 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) {
|
if (state.extras.favoritesSupported || teams.length) {
|
||||||
const bar = document.createElement('div');
|
const bar = document.createElement('div');
|
||||||
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
|
bar.style.cssText = 'display:flex;gap:8px;padding:2px 12px 10px;overflow-x:auto';
|
||||||
@@ -543,14 +546,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
btn.appendChild(num); btn.appendChild(meta);
|
btn.appendChild(num); btn.appendChild(meta);
|
||||||
if (state.extras.favoritesSupported && c.backendId) {
|
if (state.extras.favoritesSupported) {
|
||||||
const star = document.createElement('span');
|
const star = document.createElement('span');
|
||||||
const isFav = favSet.has(c.backendId);
|
const isFav = favSet.has(favId(c));
|
||||||
star.textContent = isFav ? '★' : '☆';
|
star.textContent = isFav ? '★' : '☆';
|
||||||
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
|
star.style.cssText = 'font-size:20px;padding:8px;color:' + (isFav ? '#5fd4c4' : '#6b717a');
|
||||||
star.addEventListener('click', (e) => {
|
star.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
send({ type: 'set_favorite', id: c.backendId });
|
send({ type: 'set_favorite', id: favId(c) });
|
||||||
});
|
});
|
||||||
btn.appendChild(star);
|
btn.appendChild(star);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -75,15 +76,86 @@ class AppState(
|
|||||||
private set
|
private set
|
||||||
private var lastNowTitle: String? = null
|
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<dev.castarr.tv.data.Programme> {
|
||||||
|
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). */
|
/** Digits typed on the remote's number pad (channel switching). */
|
||||||
var digitBuffer by mutableStateOf("")
|
var digitBuffer by mutableStateOf("")
|
||||||
|
|
||||||
/** False when the control server could not bind its port. */
|
/** False when the control server could not bind its port. */
|
||||||
var remoteAvailable by mutableStateOf(true)
|
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). */
|
/** Channel to restore focus to when the list comes back (#13). */
|
||||||
var lastWatched by mutableStateOf<Channel?>(null)
|
var lastWatched by mutableStateOf<Channel?>(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<Channel?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True while the list is being restored after playback. The group rail
|
* True while the list is being restored after playback. The group rail
|
||||||
* opens whatever gets focused, so without this the focus landing there
|
* opens whatever gets focused, so without this the focus landing there
|
||||||
@@ -221,6 +293,59 @@ class AppState(
|
|||||||
.sortedBy { it.second.programme.start }
|
.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<ClubNudge?>(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() {
|
fun refreshActive() {
|
||||||
when (sourceMode) {
|
when (sourceMode) {
|
||||||
SourceMode.GENERIC -> source.refresh()
|
SourceMode.GENERIC -> source.refresh()
|
||||||
@@ -257,6 +382,9 @@ class AppState(
|
|||||||
|
|
||||||
/** How far ahead a kick-off counts as "about to start". */
|
/** How far ahead a kick-off counts as "about to start". */
|
||||||
const val SOON_WINDOW_MS = 30 * 60 * 1000L
|
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 {
|
fun isOnline(): Boolean {
|
||||||
@@ -276,7 +404,10 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun play(channel: Channel) {
|
fun play(channel: Channel) {
|
||||||
|
currentChannel?.takeIf { it.url != channel.url }?.let { previousChannel = it }
|
||||||
lastWatched = channel
|
lastWatched = channel
|
||||||
|
lastWatchedUrl = channel.url
|
||||||
|
prefs.edit().putString("last_watched", channel.url).apply()
|
||||||
currentChannel = channel
|
currentChannel = channel
|
||||||
playerVisible = true
|
playerVisible = true
|
||||||
// Re-opening the very channel that was just closed can hit the
|
// Re-opening the very channel that was just closed can hit the
|
||||||
@@ -297,6 +428,7 @@ class AppState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPlayback() {
|
fun stopPlayback() {
|
||||||
|
currentChannel?.let { lastWatched = it }
|
||||||
restorePending = lastWatched != null
|
restorePending = lastWatched != null
|
||||||
reentryHandler.removeCallbacksAndMessages(null)
|
reentryHandler.removeCallbacksAndMessages(null)
|
||||||
lastStoppedUrl = currentChannel?.url.orEmpty()
|
lastStoppedUrl = currentChannel?.url.orEmpty()
|
||||||
@@ -311,6 +443,19 @@ class AppState(
|
|||||||
player.retryNow()
|
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) {
|
fun zap(direction: Int) {
|
||||||
val list = activeChannels()
|
val list = activeChannels()
|
||||||
if (list.isEmpty()) return
|
if (list.isEmpty()) return
|
||||||
|
|||||||
@@ -145,8 +145,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
.put("next", info.next?.title ?: "")
|
.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()
|
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.
|
// The club menus exist on the TV; the phone should offer them too.
|
||||||
val teams = org.json.JSONArray()
|
val teams = org.json.JSONArray()
|
||||||
dev.castarr.tv.data.TeamFilters.all
|
dev.castarr.tv.data.TeamFilters.all
|
||||||
@@ -168,7 +174,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
teams.put(
|
teams.put(
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("key", club.key)
|
.put("key", club.key)
|
||||||
.put("label", club.label)
|
.put("label", club.shortName)
|
||||||
.put("name", club.fullName)
|
.put("name", club.fullName)
|
||||||
.put("entries", entries)
|
.put("entries", entries)
|
||||||
)
|
)
|
||||||
@@ -177,7 +183,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
.put("nowNext", nowNext)
|
.put("nowNext", nowNext)
|
||||||
.put("favorites", favorites)
|
.put("favorites", favorites)
|
||||||
.put("teams", teams)
|
.put("teams", teams)
|
||||||
.put("favoritesSupported", state.sourceMode == AppState.SourceMode.DISPATCHARR)
|
.put("favoritesSupported", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
|
override fun setupNeeded(): Boolean = state.screen == AppState.Screen.WELCOME
|
||||||
@@ -224,8 +230,15 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onToggleFavorite(channelId: Int) {
|
override fun onToggleFavorite(channelId: Int) {
|
||||||
val channel = state.activeChannels().firstOrNull { it.backendId == channelId } ?: return
|
val channels = state.activeChannels()
|
||||||
state.dispatcharr.toggleFavorite(channel)
|
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)
|
mainHandler.postDelayed({ server.broadcastChannels() }, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +256,20 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
onDigit(keyCode - KeyEvent.KEYCODE_0)
|
onDigit(keyCode - KeyEvent.KEYCODE_0)
|
||||||
return true
|
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) {
|
if (state.playerVisible) {
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
// OK opens the control overlay (buttons take over from
|
// OK opens the control overlay (buttons take over from
|
||||||
@@ -251,6 +278,9 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
// reaches the activity.
|
// reaches the activity.
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
KeyEvent.KEYCODE_ENTER -> {
|
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()
|
state.pingOverlay()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -270,6 +300,13 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
state.stopPlayback()
|
state.stopPlayback()
|
||||||
return true
|
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_DPAD_UP,
|
||||||
KeyEvent.KEYCODE_CHANNEL_UP -> {
|
KeyEvent.KEYCODE_CHANNEL_UP -> {
|
||||||
state.zap(-1)
|
state.zap(-1)
|
||||||
@@ -296,6 +333,10 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
KeyEvent.KEYCODE_BACK -> {
|
KeyEvent.KEYCODE_BACK -> {
|
||||||
|
if (state.clubNudge != null) {
|
||||||
|
state.dismissClubNudge()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (state.overlayVisible) state.overlayVisible = false
|
if (state.overlayVisible) state.overlayVisible = false
|
||||||
else state.stopPlayback()
|
else state.stopPlayback()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
val epgUpdatedAt = MutableStateFlow(0L)
|
val epgUpdatedAt = MutableStateFlow(0L)
|
||||||
val status = MutableStateFlow("")
|
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<String, List<Programme>> = emptyMap()
|
private var programmesByTvgId: Map<String, List<Programme>> = emptyMap()
|
||||||
var outputProfile: String = ""
|
var outputProfile: String = ""
|
||||||
|
|
||||||
@@ -70,6 +76,11 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
|||||||
list.size
|
list.size
|
||||||
}
|
}
|
||||||
result.onFailure { Log.w(TAG, "refresh failed: ${it.javaClass.simpleName}: ${it.message?.take(160)}") }
|
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"
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
onDone(result)
|
onDone(result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ class SourceRepository(context: Context) {
|
|||||||
val epgUpdatedAt = MutableStateFlow(0L)
|
val epgUpdatedAt = MutableStateFlow(0L)
|
||||||
val status = MutableStateFlow("")
|
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<String, List<Programme>> = emptyMap()
|
private var programmes: Map<String, List<Programme>> = emptyMap()
|
||||||
private var nameToEpgId: Map<String, String> = emptyMap()
|
private var nameToEpgId: Map<String, String> = emptyMap()
|
||||||
|
|
||||||
@@ -62,6 +68,11 @@ class SourceRepository(context: Context) {
|
|||||||
parsed.size
|
parsed.size
|
||||||
}
|
}
|
||||||
result.onFailure { Log.w(TAG, "channel refresh failed", it) }
|
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"
|
status.value = if (result.isSuccess) "" else "channels_error"
|
||||||
onDone(result)
|
onDone(result)
|
||||||
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
|
if (result.isSuccess && epg.isNotEmpty()) refreshEpg(epg)
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.Color
|
|||||||
*/
|
*/
|
||||||
data class TeamFilter(
|
data class TeamFilter(
|
||||||
val key: String,
|
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,
|
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. */
|
/** Full club name, shown in the settings and in messages. */
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
/** Lowercase needles matched against programme titles. */
|
/** Lowercase needles matched against programme titles. */
|
||||||
@@ -60,75 +65,76 @@ object TeamFilters {
|
|||||||
private fun club(
|
private fun club(
|
||||||
key: String,
|
key: String,
|
||||||
label: String,
|
label: String,
|
||||||
|
shortName: String,
|
||||||
fullName: String,
|
fullName: String,
|
||||||
needles: List<String>,
|
needles: List<String>,
|
||||||
primary: Long,
|
primary: Long,
|
||||||
secondary: Long,
|
secondary: Long,
|
||||||
article: String = fullName,
|
article: String = fullName,
|
||||||
league: Int = 1,
|
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. */
|
/** Clubs of the top three German divisions. */
|
||||||
val all: List<TeamFilter> = listOf(
|
val all: List<TeamFilter> = listOf(
|
||||||
// --- Bundesliga ---
|
// --- Bundesliga ---
|
||||||
club("bayern", "FCB", "FC Bayern München", listOf("bayern münchen", "fc bayern"), 0xFFDC052D, WHITE, league = 1),
|
club("bayern", "FCB", "Bayern", "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("bvb", "BVB", "Dortmund", "Borussia Dortmund", listOf("borussia dortmund", "bvb"), 0xFFFDE100, BLACK, league = 1),
|
||||||
club("leipzig", "RBL", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
|
club("leipzig", "RBL", "Leipzig", "RB Leipzig", listOf("rb leipzig"), 0xFFDD0741, WHITE, league = 1),
|
||||||
club("leverkusen", "B04", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
|
club("leverkusen", "B04", "Leverkusen", "Bayer 04 Leverkusen", listOf("leverkusen"), 0xFFE32219, BLACK, league = 1),
|
||||||
club("frankfurt", "SGE", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
|
club("frankfurt", "SGE", "Frankfurt", "Eintracht Frankfurt", listOf("eintracht frankfurt"), 0xFF1A1A1A, 0xFFE1000F, league = 1),
|
||||||
club("stuttgart", "1893", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
|
club("stuttgart", "1893", "Stuttgart", "VfB Stuttgart", listOf("vfb stuttgart"), 0xFFE32219, WHITE, league = 1),
|
||||||
club("gladbach", "BMG", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
|
club("gladbach", "BMG", "Gladbach", "Borussia Mönchengladbach", listOf("mönchengladbach", "gladbach"), 0xFF1A1A1A, 0xFF00A94F, league = 1),
|
||||||
club("wolfsburg", "WOB", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
|
club("wolfsburg", "WOB", "Wolfsburg", "VfL Wolfsburg", listOf("wolfsburg"), 0xFF65B32E, WHITE, league = 1),
|
||||||
club("bremen", "SVW", "SV Werder Bremen", listOf("werder bremen", "werder"), 0xFF1D9053, WHITE, league = 1),
|
club("bremen", "SVW", "Werder", "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("freiburg", "SCF", "Freiburg", "SC Freiburg", listOf("sc freiburg", "freiburg"), 0xFFE2001A, WHITE, league = 1),
|
||||||
club("hoffenheim", "TSG", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
|
club("hoffenheim", "TSG", "Hoffenheim", "TSG Hoffenheim", listOf("hoffenheim"), 0xFF1C63B7, WHITE, league = 1),
|
||||||
club("mainz", "M05", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
|
club("mainz", "M05", "Mainz", "1. FSV Mainz 05", listOf("mainz 05", "mainz"), 0xFFE1000F, WHITE, league = 1),
|
||||||
club("augsburg", "FCA", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
|
club("augsburg", "FCA", "Augsburg", "FC Augsburg", listOf("augsburg"), 0xFF00693F, 0xFFE1000F, league = 1),
|
||||||
club("union", "FCU", "1. FC Union Berlin", listOf("union berlin"), 0xFFE1000F, 0xFFFDE100, league = 1),
|
club("union", "FCU", "Union Berlin", "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("koeln", "KOE", "Köln", "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("hsv", "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("heidenheim", "HDH", "Heidenheim", "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("st-pauli", "FCSP", "St. Pauli", "FC St. Pauli", listOf("st. pauli", "st pauli"), 0xFF6B4423, WHITE, league = 1),
|
||||||
// --- 2. Bundesliga ---
|
// --- 2. Bundesliga ---
|
||||||
club("schalke", "S04", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
club("schalke", "S04", "Schalke", "FC Schalke 04", listOf("schalke"), 0xFF004D9D, WHITE, league = 2),
|
||||||
club("hertha", "BSC", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
|
club("hertha", "BSC", "Hertha", "Hertha BSC", listOf("hertha"), 0xFF004D9D, WHITE, league = 2),
|
||||||
club("duesseldorf", "F95", "Fortuna Düsseldorf", listOf("fortuna düsseldorf"), 0xFFE1000F, WHITE, league = 2),
|
club("duesseldorf", "F95", "Düsseldorf", "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("nuernberg", "FCN", "Nürnberg", "1. FC Nürnberg", listOf("nürnberg"), 0xFF8B1A1A, WHITE, league = 2),
|
||||||
club("kaiserslautern", "FCK", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
|
club("kaiserslautern", "FCK", "Kaiserslautern", "1. FC Kaiserslautern", listOf("kaiserslautern"), 0xFFE1000F, WHITE, league = 2),
|
||||||
club("karlsruhe", "KSC", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
|
club("karlsruhe", "KSC", "Karlsruhe", "Karlsruher SC", listOf("karlsruher sc", "ksc"), 0xFF0055A5, WHITE, league = 2),
|
||||||
club("hannover", "H96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
|
club("hannover", "H96", "Hannover 96", "Hannover 96", listOf("hannover 96"), 0xFF00A94F, WHITE, league = 2),
|
||||||
club("paderborn", "SCP", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
|
club("paderborn", "SCP", "Paderborn", "SC Paderborn 07", listOf("paderborn"), 0xFF004D9D, WHITE, league = 2),
|
||||||
club("magdeburg", "FCM", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
|
club("magdeburg", "FCM", "Magdeburg", "1. FC Magdeburg", listOf("magdeburg"), 0xFF0B7A3E, WHITE, league = 2),
|
||||||
club("elversberg", "SVE", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
|
club("elversberg", "SVE", "Elversberg", "SV Elversberg", listOf("elversberg"), 0xFF1A1A1A, 0xFFE1000F, league = 2),
|
||||||
club("darmstadt", "SV98", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
|
club("darmstadt", "SV98", "Darmstadt", "SV Darmstadt 98", listOf("darmstadt"), 0xFF004E9E, WHITE, league = 2),
|
||||||
club("braunschweig", "BTSV", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
|
club("braunschweig", "BTSV", "Braunschweig", "Eintracht Braunschweig", listOf("braunschweig"), 0xFFFDE100, 0xFF004E9E, league = 2),
|
||||||
club("bochum", "BOC", "VfL Bochum", listOf("bochum"), 0xFF005CA9, WHITE, league = 2),
|
club("bochum", "BOC", "Bochum", "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("muenster", "SCPM", "Münster", "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("fuerth", "SGF", "Fürth", "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("holstein", "KSV", "Kiel", "Holstein Kiel", listOf("holstein kiel"), 0xFF004E9E, 0xFFE1000F, league = 2),
|
||||||
club("dresden", "SGD", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
|
club("dresden", "SGD", "Dresden", "Dynamo Dresden", listOf("dynamo dresden"), 0xFFFDE100, BLACK, league = 2),
|
||||||
club("bielefeld", "DSC", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
|
club("bielefeld", "DSC", "Bielefeld", "Arminia Bielefeld", listOf("arminia bielefeld", "bielefeld"), 0xFF00539F, WHITE, league = 2),
|
||||||
// --- 3. Liga ---
|
// --- 3. Liga ---
|
||||||
club("hansa", "FCH", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
|
club("hansa", "FCH", "Rostock", "FC Hansa Rostock", listOf("hansa"), 0xFF0B4EA2, WHITE, league = 3),
|
||||||
club("saarbruecken", "FCS", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
|
club("saarbruecken", "FCS", "Saarbrücken", "1. FC Saarbrücken", listOf("saarbrücken"), 0xFF0B4EA2, BLACK, league = 3),
|
||||||
club("aue", "FCE", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
|
club("aue", "FCE", "Aue", "Erzgebirge Aue", listOf("erzgebirge aue"), 0xFF7C0A02, WHITE, league = 3),
|
||||||
club("cottbus", "FCEC", "Energie Cottbus", listOf("energie cottbus", "cottbus"), 0xFFE1000F, WHITE, league = 3),
|
club("cottbus", "FCEC", "Cottbus", "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("essen", "RWE", "Essen", "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("duisburg", "MSV", "Duisburg", "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("mannheim", "SVWM", "Mannheim", "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("wehen", "SVWW", "Wehen Wiesbaden", "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("ulm", "SSV", "Ulm", "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("regensburg", "SSVJ", "Regensburg", "SSV Jahn Regensburg", listOf("jahn regensburg", "regensburg"), 0xFFE1000F, WHITE, league = 3),
|
||||||
club("verl", "SCV", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
|
club("verl", "SCV", "Verl", "SC Verl", listOf("sc verl"), 0xFF00703C, WHITE, league = 3),
|
||||||
club("viktoria", "VKÖ", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
|
club("viktoria", "VKÖ", "Viktoria Köln", "Viktoria Köln", listOf("viktoria köln"), 0xFFE1000F, WHITE, league = 3),
|
||||||
club("havelse", "TSVH", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
|
club("havelse", "TSVH", "Havelse", "TSV Havelse", listOf("havelse"), 0xFF004E9E, WHITE, league = 3),
|
||||||
club("schweinfurt", "FC05", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
|
club("schweinfurt", "FC05", "Schweinfurt", "1. FC Schweinfurt 05", listOf("schweinfurt"), 0xFF00A94F, WHITE, league = 3),
|
||||||
club("osnabrueck", "VfLO", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
|
club("osnabrueck", "VfLO", "Osnabrück", "VfL Osnabrück", listOf("osnabrück"), 0xFF6A2C8F, WHITE, league = 3),
|
||||||
club("aachen", "ALE", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
|
club("aachen", "ALE", "Aachen", "Alemannia Aachen", listOf("alemannia aachen", "aachen"), 0xFFFDE100, BLACK, league = 3),
|
||||||
club("ingolstadt", "FCI", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
|
club("ingolstadt", "FCI", "Ingolstadt", "FC Ingolstadt 04", listOf("ingolstadt"), 0xFFE1000F, BLACK, league = 3),
|
||||||
club("wuppertal", "WSV", "Wuppertaler SV", listOf("wuppertaler sv"), 0xFFE1000F, 0xFF0B4EA2, league = 3),
|
club("wuppertal", "WSV", "Wuppertal", "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("stuttgart-ii", "VfB2", "Stuttgart II", "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("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. */
|
/** Clubs switched on for a viewer before they touch the settings. */
|
||||||
@@ -156,6 +162,30 @@ object TeamFilters {
|
|||||||
return groupIncludes.any { g.contains(it) }
|
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 }
|
fun byKey(key: String): TeamFilter? = all.firstOrNull { it.key == key }
|
||||||
|
|
||||||
/** Clubs grouped by division, in the order the picker shows them. */
|
/** Clubs grouped by division, in the order the picker shows them. */
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ object Pairing {
|
|||||||
prefs(context).edit().clear().apply()
|
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<String> =
|
private fun sessionTokens(context: Context): List<String> =
|
||||||
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
|
prefs(context).getStringSet(KEY_SESSIONS, emptySet())?.toList().orEmpty()
|
||||||
|
|
||||||
|
|||||||
@@ -54,19 +54,22 @@ fun AdvancedScreen(state: AppState) {
|
|||||||
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(18.dp))
|
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(
|
TvTextField(
|
||||||
value = m3u,
|
value = m3u,
|
||||||
onValueChange = { m3u = it },
|
onValueChange = { m3u = it },
|
||||||
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),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
|
FieldLabel("XMLTV-EPG-URL (optional)")
|
||||||
TvTextField(
|
TvTextField(
|
||||||
value = epg,
|
value = epg,
|
||||||
onValueChange = { epg = it },
|
onValueChange = { epg = it },
|
||||||
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),
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
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.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.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.layout.wrapContentSize
|
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.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.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.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
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.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.foundation.shape.CircleShape
|
import androidx.tv.material3.Border
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.LocalContentColor
|
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 kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -69,6 +79,7 @@ fun CastarrApp(state: AppState) {
|
|||||||
if (state.digitBuffer.isNotEmpty()) {
|
if (state.digitBuffer.isNotEmpty()) {
|
||||||
DigitBadge(state)
|
DigitBadge(state)
|
||||||
}
|
}
|
||||||
|
ClubNudge(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +129,8 @@ private fun TopBar(state: AppState) {
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
letterSpacing = 4.sp,
|
letterSpacing = 4.sp,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(24.dp))
|
||||||
|
StaleNotice(state)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
state.connectedRemote?.let { name ->
|
state.connectedRemote?.let { name ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
150
app/src/main/java/dev/castarr/tv/ui/DayPlan.kt
Normal file
150
app/src/main/java/dev/castarr/tv/ui/DayPlan.kt
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,10 +153,13 @@ private fun LogoInitials(initials: String) {
|
|||||||
internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
internal fun HighlightCell(hit: AppState.TeamHit, modifier: Modifier = Modifier) {
|
||||||
val programme = hit.programme
|
val programme = hit.programme
|
||||||
val running = System.currentTimeMillis() in programme.start until programme.stop
|
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) {
|
Column(modifier) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
Text(
|
Text(
|
||||||
programme.title,
|
title,
|
||||||
color = CastarrColors.fg,
|
color = CastarrColors.fg,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 14.sp,
|
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).
|
* under the title, next programme only when it differs (design review P1/P2).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) }
|
val now = nowNext.now?.takeUnless { isEpgPlaceholder(it.title) }
|
||||||
Column(modifier) {
|
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) {
|
if (now != null) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ internal fun ChannelRow(
|
|||||||
// whatever happens to be running.
|
// whatever happens to be running.
|
||||||
HighlightCell(highlight, Modifier.weight(1f))
|
HighlightCell(highlight, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
EpgCell(nowNext, Modifier.weight(1f))
|
EpgCell(nowNext, Modifier.weight(1f), fallback = channel.group)
|
||||||
}
|
}
|
||||||
if (playing) {
|
if (playing) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|||||||
@@ -13,28 +13,28 @@ 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.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.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.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
|
||||||
@@ -85,7 +85,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
|
val teamHits = remember(activeMatches) { activeMatches.associate { it.first.url to it.second } }
|
||||||
val channels = when {
|
val channels = when {
|
||||||
activeTeam != null -> activeMatches.map { it.first }
|
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 }
|
state.groupFilter != null -> allChannels.filter { it.group == state.groupFilter }
|
||||||
else -> allChannels
|
else -> allChannels
|
||||||
}
|
}
|
||||||
@@ -110,11 +110,46 @@ fun LiveScreen(state: AppState) {
|
|||||||
val restoreFocus = remember { FocusRequester() }
|
val restoreFocus = remember { FocusRequester() }
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
// Index of the channel the viewer last watched, so leaving playback
|
// Index of the channel the viewer last watched, so leaving playback
|
||||||
// returns them to where they were instead of the top of the list.
|
// returns them to where they were instead of the top of the list. The
|
||||||
val restoreIndex = state.lastWatched?.let { watched ->
|
// URL survives a restart, so switching the TV on lands there too.
|
||||||
channels.indexOfFirst { it.url == watched.url }.takeIf { it >= 0 }
|
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) }
|
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<Int?>(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<dev.castarr.tv.playlist.Channel?>(null) }
|
||||||
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
LaunchedEffect(state.groupFilter, state.favoritesOnly) {
|
||||||
// A fresh group starts at the top; a return from playback does not.
|
// A fresh group starts at the top; a return from playback does not.
|
||||||
if (restoreIndex == null) listState.scrollToItem(0)
|
if (restoreIndex == null) listState.scrollToItem(0)
|
||||||
@@ -125,6 +160,10 @@ fun LiveScreen(state: AppState) {
|
|||||||
runCatching { restoreFocus.requestFocus() }
|
runCatching { restoreFocus.requestFocus() }
|
||||||
restored = true
|
restored = true
|
||||||
state.restorePending = false
|
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
|
// A FocusRequester that is not currently attached throws when used, and
|
||||||
@@ -164,11 +203,12 @@ fun LiveScreen(state: AppState) {
|
|||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isDispatcharr) {
|
// Favourites exist for both source types now, so the entry is
|
||||||
|
// no longer tied to being signed in.
|
||||||
item {
|
item {
|
||||||
GroupItem(
|
GroupItem(
|
||||||
label = "★ Favoriten",
|
label = "★ Favoriten",
|
||||||
count = favorites.size,
|
count = state.favoriteCount(),
|
||||||
selected = state.favoritesOnly && state.activeTeam == null,
|
selected = state.favoritesOnly && state.activeTeam == null,
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
if (state.favoritesOnly) Modifier.focusRequester(railFocus) else Modifier
|
||||||
@@ -180,14 +220,13 @@ fun LiveScreen(state: AppState) {
|
|||||||
state.groupFilter = null
|
state.groupFilter = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
items(teams, key = { it.key }) { club ->
|
items(teams, key = { it.key }) { club ->
|
||||||
val clubMatches = matchesByTeam[club.key].orEmpty()
|
val clubMatches = matchesByTeam[club.key].orEmpty()
|
||||||
val clubSignal = remember(clubMatches, urgencyTick) {
|
val clubSignal = remember(clubMatches, urgencyTick) {
|
||||||
state.teamSignal(clubMatches)
|
state.teamSignal(clubMatches)
|
||||||
}
|
}
|
||||||
GroupItem(
|
GroupItem(
|
||||||
label = club.label,
|
label = club.shortName,
|
||||||
count = clubMatches.size,
|
count = clubMatches.size,
|
||||||
selected = state.activeTeam == club.key,
|
selected = state.activeTeam == club.key,
|
||||||
leading = { Crest(club, state, clubSignal.urgency) },
|
leading = { Crest(club, state, clubSignal.urgency) },
|
||||||
@@ -263,31 +302,48 @@ fun LiveScreen(state: AppState) {
|
|||||||
// matching the remote's number pad in every view.
|
// matching the remote's number pad in every view.
|
||||||
number = allChannels.indexOf(channel) + 1,
|
number = allChannels.indexOf(channel) + 1,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.onFocusChanged { if (it.isFocused) focusedIndex = listIndex }
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
event.type == KeyEventType.KeyDown &&
|
if (event.type != KeyEventType.KeyDown) {
|
||||||
event.key == Key.DirectionLeft &&
|
return@onPreviewKeyEvent false
|
||||||
|
}
|
||||||
|
when (event.key) {
|
||||||
|
Key.DirectionLeft ->
|
||||||
runCatching { railFocus.requestFocus() }.isSuccess
|
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 == 0) Modifier.focusRequester(listFocus) else Modifier)
|
||||||
.then(
|
.then(
|
||||||
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
if (listIndex == restoreIndex) Modifier.focusRequester(restoreFocus)
|
||||||
else Modifier
|
else Modifier
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (listIndex == jumpIndex) Modifier.focusRequester(jumpFocus)
|
||||||
|
else Modifier
|
||||||
),
|
),
|
||||||
nowNext = state.nowNext(channel),
|
nowNext = state.nowNext(channel),
|
||||||
highlight = teamHits[channel.url],
|
highlight = teamHits[channel.url],
|
||||||
playing = state.currentChannel?.url == channel.url,
|
playing = state.currentChannel?.url == channel.url,
|
||||||
// All rows are favorites in the favorites view — the
|
// All rows are favorites in the favorites view — the
|
||||||
// star only carries meaning elsewhere.
|
// star only carries meaning elsewhere.
|
||||||
favorite = isDispatcharr && !state.favoritesOnly &&
|
favorite = !state.favoritesOnly && state.isFavorite(channel),
|
||||||
channel.backendId in favorites,
|
|
||||||
epgStamp = epgStamp,
|
epgStamp = epgStamp,
|
||||||
onLongClick = if (isDispatcharr) {
|
onLongClick = { state.toggleFavorite(channel) },
|
||||||
{ state.dispatcharr.toggleFavorite(channel) }
|
|
||||||
} else null,
|
|
||||||
) { state.play(channel) }
|
) { state.play(channel) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dayPlanFor?.let { channel ->
|
||||||
|
DayPlanDialog(state, channel) { dayPlanFor = null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -357,7 +413,7 @@ private fun GroupItem(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
|
if (urgent == AppState.TeamUrgency.LIVE) "läuft"
|
||||||
else formatClock(signal!!.kickOff),
|
else "ab ${formatClock(signal!!.kickOff)}",
|
||||||
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
|
color = if (urgent == AppState.TeamUrgency.LIVE) CastarrColors.live
|
||||||
else CastarrColors.accent,
|
else CastarrColors.accent,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ 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.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -27,6 +29,11 @@ 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.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.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
|
||||||
@@ -91,6 +98,35 @@ internal fun ClubPickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val firstKey = sections.firstOrNull()?.second?.firstOrNull()?.key
|
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)) {
|
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -112,9 +148,28 @@ internal fun ClubPickerDialog(
|
|||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
letterSpacing = 2.sp,
|
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) ->
|
sections.forEach { (league, clubs) ->
|
||||||
item(key = "h-$league") {
|
item(key = "h-$league") {
|
||||||
Text(
|
Text(
|
||||||
@@ -128,9 +183,12 @@ internal fun ClubPickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(clubs, key = { it.key }) { club ->
|
items(clubs, key = { it.key }) { club ->
|
||||||
val focusMod =
|
val focusMod = when (club.key) {
|
||||||
if (club.key == firstKey) Modifier.focusRequester(firstFocus)
|
firstKey -> Modifier.focusRequester(firstFocus)
|
||||||
else Modifier
|
leagueFirstKeys.getOrNull(leagueIndex) ->
|
||||||
|
Modifier.focusRequester(leagueFocus)
|
||||||
|
else -> Modifier
|
||||||
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onPick(club) },
|
onClick = { onPick(club) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package dev.castarr.tv.ui
|
package dev.castarr.tv.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.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
|
||||||
@@ -14,9 +12,12 @@ 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.rememberScrollState
|
||||||
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.foundation.verticalScroll
|
||||||
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
|
||||||
@@ -26,6 +27,8 @@ 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.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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
|
||||||
@@ -61,18 +64,23 @@ fun SettingsScreen(state: AppState) {
|
|||||||
var clubPicker by remember { mutableStateOf(false) }
|
var clubPicker by remember { mutableStateOf(false) }
|
||||||
// Bumped on reset so the QR code and the four-digit code redraw.
|
// Bumped on reset so the QR code and the four-digit code redraw.
|
||||||
var pairingEpoch by remember { mutableStateOf(0) }
|
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(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 40.dp, vertical = 12.dp)
|
.padding(horizontal = 40.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
// The cards outgrew one screen once club menus arrived; focus
|
// Three columns instead of two: the cards outgrew one screen once
|
||||||
// movement scrolls this column along.
|
// club menus arrived, and the ten-foot rule (CONTEXT.md) allows no
|
||||||
|
// scrolling outside the channel list.
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier.weight(1f),
|
||||||
.weight(1.25f)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
SettingsCard("Konto") {
|
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 {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Nicht angemeldet.",
|
"Nicht angemeldet.",
|
||||||
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
|
color = CastarrColors.muted, fontFamily = AppFont, fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
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 ->
|
active.forEach { club ->
|
||||||
SettingRow(
|
SettingRow(
|
||||||
club.fullName,
|
club.fullName,
|
||||||
subtitle = "Erscheint als ${club.label} in der Senderliste · Entfernen",
|
subtitle = "Erscheint als ${club.shortName} in der Senderliste · Entfernen",
|
||||||
leading = { ClubCrest(club, state) },
|
leading = { ClubCrest(club, state) },
|
||||||
trailing = { RemoveIcon() },
|
trailing = { RemoveIcon() },
|
||||||
) { state.toggleTeam(club.key) }
|
) { 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") {
|
SettingsCard("App") {
|
||||||
SettingRow(
|
SettingRow(
|
||||||
"Version ${BuildConfig.VERSION_NAME}",
|
"Version ${BuildConfig.VERSION_NAME}",
|
||||||
@@ -236,7 +259,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
|
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
|
|
||||||
Column(Modifier.weight(0.75f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
SettingsCard("Handy-Fernbedienung") {
|
SettingsCard("Handy-Fernbedienung") {
|
||||||
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
||||||
if (!state.remoteAvailable) {
|
if (!state.remoteAvailable) {
|
||||||
@@ -273,10 +296,7 @@ fun SettingsScreen(state: AppState) {
|
|||||||
SettingRow(
|
SettingRow(
|
||||||
"Kopplung zurücksetzen",
|
"Kopplung zurücksetzen",
|
||||||
subtitle = "Neuer Code, alle Handys müssen neu scannen",
|
subtitle = "Neuer Code, alle Handys müssen neu scannen",
|
||||||
) {
|
) { confirmPairingReset = true }
|
||||||
Pairing.reset(context)
|
|
||||||
pairingEpoch++
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Keine Netzwerkverbindung",
|
"Keine Netzwerkverbindung",
|
||||||
@@ -291,6 +311,26 @@ fun SettingsScreen(state: AppState) {
|
|||||||
picker?.let { current ->
|
picker?.let { current ->
|
||||||
PickerDialog(current) { picker = null }
|
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) {
|
if (clubPicker) {
|
||||||
ClubPickerDialog(
|
ClubPickerDialog(
|
||||||
state = state,
|
state = state,
|
||||||
|
|||||||
@@ -239,11 +239,12 @@ internal fun SettingRow(
|
|||||||
danger: Boolean = false,
|
danger: Boolean = false,
|
||||||
leading: (@Composable () -> Unit)? = null,
|
leading: (@Composable () -> Unit)? = null,
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
shape = ClickableSurfaceDefaults.shape(rowShape),
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
@@ -282,7 +283,7 @@ internal fun SettingRow(
|
|||||||
color = CastarrColors.faint,
|
color = CastarrColors.faint,
|
||||||
fontFamily = AppFont,
|
fontFamily = AppFont,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -296,9 +297,15 @@ internal fun SettingRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ActionButton(label: String, danger: Boolean = false, onClick: () -> Unit) {
|
fun ActionButton(
|
||||||
|
label: String,
|
||||||
|
danger: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(10.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (danger) CastarrColors.surface else CastarrColors.accentDim,
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
fun TvTextField(
|
fun TvTextField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
label: @Composable () -> Unit,
|
|
||||||
colors: TextFieldColors,
|
colors: TextFieldColors,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
textStyle: TextStyle = TextStyle.Default,
|
textStyle: TextStyle = TextStyle.Default,
|
||||||
@@ -30,7 +29,6 @@ fun TvTextField(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
label = label,
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
colors = colors,
|
colors = colors,
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ fun WelcomeScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
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,
|
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ p = argparse.ArgumentParser()
|
|||||||
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
|
p.add_argument("--out", default=os.path.dirname(os.path.abspath(__file__)))
|
||||||
p.add_argument("--kickoff-in", type=int, default=18,
|
p.add_argument("--kickoff-in", type=int, default=18,
|
||||||
help="minutes until the club match starts")
|
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()
|
args = p.parse_args()
|
||||||
|
|
||||||
|
if args.now:
|
||||||
|
now = datetime.datetime.strptime(args.now, "%Y-%m-%dT%H:%M:%S").astimezone()
|
||||||
|
else:
|
||||||
now = datetime.datetime.now().astimezone()
|
now = datetime.datetime.now().astimezone()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,12 @@ step "Emulator starten"
|
|||||||
"$HELPERS/emulator.sh" start | tee -a "$LOG"
|
"$HELPERS/emulator.sh" start | tee -a "$LOG"
|
||||||
|
|
||||||
step "Demo-Quelle bereitstellen"
|
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 emulator reaches the host at 10.0.2.2, which is the address baked into
|
||||||
# the demo playlist.
|
# the demo playlist.
|
||||||
# A leftover server from an earlier run happily answers on this port while
|
# A leftover server from an earlier run happily answers on this port while
|
||||||
|
|||||||
59
tests/unit/FixtureTest.kt
Normal file
59
tests/unit/FixtureTest.kt
Normal file
@@ -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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user