diff --git a/app/src/main/assets/remote/index.html b/app/src/main/assets/remote/index.html index 2279ca0..17971ac 100644 --- a/app/src/main/assets/remote/index.html +++ b/app/src/main/assets/remote/index.html @@ -168,7 +168,7 @@

Mit dem TV koppeln

-

Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.

+

Gib den 4-stelligen Code ein, der auf dem Fernseher unter dem QR-Code steht.

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