From 6369249f403b6f54efb9433206594982536966c1 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 22 Oct 2026 23:57:22 +0200 Subject: [PATCH] feat: implement LiveTicker functionality with sound notifications and UI updates --- .../java/de/harheimertc/data/ApiService.kt | 19 +++++ .../repositories/LiveTickerRepository.kt | 17 ++++ .../ui/components/LiveTickerBanner.kt | 78 +++++++++++++++++++ .../ui/components/LiveTickerSound.kt | 15 ++++ .../de/harheimertc/ui/navigation/NavGraph.kt | 10 +++ .../ui/navigation/NavigationViewModel.kt | 31 ++++++++ android-app/gradle.properties | 4 +- components/LiveTickerBanner.vue | 75 +++++++++++++++++- package.json | 2 +- server/utils/live-ticker.js | 7 +- 10 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index 690fd28..f5936b1 100755 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -37,6 +37,22 @@ data class SpielplanResponse( val season: String? = null, val seasons: List = emptyList(), ) +data class LiveTickerResponse( + val matches: List = emptyList(), + val updatedAt: String? = null, +) +data class LiveTickerMatchDto( + val meetingId: String = "", + val homeTeam: String = "", + val awayTeam: String = "", + val score: String = "", + val startsAt: Long = 0, + val hasLiveData: Boolean = false, + val completed: Boolean = false, + val completedAt: Long? = null, + val updatedAt: String? = null, + val error: String? = null, +) data class SeasonDto(val slug: String = "", val label: String = "") data class MannschaftenSeasonsResponse( val success: Boolean = false, @@ -726,6 +742,9 @@ interface ApiService { @GET("/api/mitgliederbereich/qttr") suspend fun qttrValues(): Response + @GET("/api/mitgliederbereich/live-ticker") + suspend fun liveTicker(): Response + @GET("/api/members") suspend fun members(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt new file mode 100644 index 0000000..00d7d25 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/LiveTickerRepository.kt @@ -0,0 +1,17 @@ +package de.harheimertc.repositories + +import de.harheimertc.data.ApiService +import de.harheimertc.data.LiveTickerMatchDto +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LiveTickerRepository @Inject constructor(private val api: ApiService) { + suspend fun fetch(): Result> = runCatching { + retryOnNetworkFailure { + val response = api.liveTicker() + if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.") + response.body()?.matches ?: emptyList() + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt new file mode 100644 index 0000000..0243fe7 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerBanner.kt @@ -0,0 +1,78 @@ +package de.harheimertc.ui.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.harheimertc.data.LiveTickerMatchDto + +@Composable +fun LiveTickerBanner(matches: List, loaded: Boolean) { + var observedLoadedState by remember { mutableStateOf(false) } + var previousStates by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(matches, loaded) { + if (!loaded) { + observedLoadedState = false + previousStates = emptyMap() + return@LaunchedEffect + } + val currentStates = matches.associate { match -> + (match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to + "${match.hasLiveData}|${match.score}|${match.completed}" + } + if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) { + LiveTickerSound.play() + } + previousStates = currentStates + observedLoadedState = true + } + + if (matches.isEmpty()) return + + Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 9.dp), + ) { + matches.forEachIndexed { index, match -> + if (index > 0) { + Text(" • ", color = Color(0xFF64748B)) + } + val status = when { + match.completed -> "✓ beendet" + match.hasLiveData -> "● LIVE" + else -> "◌ Keine Live-Daten" + } + Text( + text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}", + color = when { + match.completed -> Color(0xFF64748B) + match.hasLiveData -> Color(0xFFB91C1C) + else -> Color(0xFF334155) + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold, + fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal, + maxLines = 1, + ) + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt new file mode 100644 index 0000000..be4c21a --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/LiveTickerSound.kt @@ -0,0 +1,15 @@ +package de.harheimertc.ui.components + +import android.media.AudioManager +import android.media.ToneGenerator + +/** A generated system tone; no audio asset or third-party licence is required. */ +object LiveTickerSound { + private val toneGenerator by lazy { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 65) } + + fun play() { + runCatching { + toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP2, 170) + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index 74b063a..4d6f3e1 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -25,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import de.harheimertc.ui.components.AppNavigationHeader +import de.harheimertc.ui.components.LiveTickerBanner @Composable fun NavGraph( @@ -44,6 +45,11 @@ fun NavGraph( LaunchedEffect(currentRoute) { navigationViewModel.refreshSession() } + LaunchedEffect(currentRoute, navigationState.loggedIn) { + navigationViewModel.setLiveTickerActive( + navigationState.loggedIn && (currentRoute == "intern" || currentRoute?.startsWith("intern/") == true || currentRoute == "cms" || currentRoute?.startsWith("cms/") == true), + ) + } BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val persistentNavigation = maxWidth >= 600.dp Column(modifier = Modifier.fillMaxSize()) { @@ -72,6 +78,10 @@ fun NavGraph( navigationState = navigationState, ) } + LiveTickerBanner( + matches = navigationState.liveTickerMatches, + loaded = navigationState.liveTickerLoaded, + ) NavHost( navController = navController, startDestination = startDestination, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt index 84ed639..41c45da 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt @@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.data.ConnectivityMonitor +import de.harheimertc.data.LiveTickerMatchDto import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.LoginRepository +import de.harheimertc.repositories.LiveTickerRepository import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.PushTokenRepository import kotlinx.coroutines.async +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,6 +27,8 @@ data class NavigationUiState( val loggedIn: Boolean = false, val roles: Set = emptySet(), val connectionNote: String? = null, + val liveTickerMatches: List = emptyList(), + val liveTickerLoaded: Boolean = false, ) { val isAdmin: Boolean get() = "admin" in roles val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") } @@ -39,9 +46,12 @@ class NavigationViewModel @Inject constructor( private val authRepository: AuthRepository, private val connectivityMonitor: ConnectivityMonitor, private val pushTokenRepository: PushTokenRepository, + private val liveTickerRepository: LiveTickerRepository, ) : ViewModel() { private val _state = MutableStateFlow(NavigationUiState()) val state: StateFlow = _state + private var tickerJob: Job? = null + private var tickerActive = false init { loadNavigationData() @@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor( loggedIn = loggedIn, roles = status.navigationRoles(), connectionNote = null, + liveTickerMatches = _state.value.liveTickerMatches, + liveTickerLoaded = _state.value.liveTickerLoaded, ) if (loggedIn) registerPushToken() } @@ -89,6 +101,24 @@ class NavigationViewModel @Inject constructor( } } + fun setLiveTickerActive(active: Boolean) { + if (tickerActive == active) return + tickerActive = active + tickerJob?.cancel() + if (!active) { + _state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false) + return + } + tickerJob = viewModelScope.launch { + while (isActive) { + liveTickerRepository.fetch().onSuccess { matches -> + _state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true) + } + delay(30_000) + } + } + } + private fun registerPushToken() { viewModelScope.launch { pushTokenRepository.registerCurrentDevice() @@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor( } fun logout(onComplete: () -> Unit = {}) { + setLiveTickerActive(false) viewModelScope.launch { loginRepository.logout() _state.value = _state.value.copy( diff --git a/android-app/gradle.properties b/android-app/gradle.properties index a6b0cd0..9b70bac 100755 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=37 -ANDROID_VERSION_NAME=0.10.1 +ANDROID_VERSION_CODE=38 +ANDROID_VERSION_NAME=0.11.0 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/components/LiveTickerBanner.vue b/components/LiveTickerBanner.vue index abd6a95..d4ba933 100644 --- a/components/LiveTickerBanner.vue +++ b/components/LiveTickerBanner.vue @@ -4,6 +4,14 @@ class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm" >
+
authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms'))) let timer -async function load() { - if (!isInternal.value) { matches.value = []; return } - try { matches.value = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || [] } catch { matches.value = [] } +let audioContext +let tickerLoaded = false +let previousStates = new Map() + +function stateByMeeting(items) { + return new Map(items.map(match => [ + match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`, + `${match.hasLiveData}|${match.score}|${match.completed}` + ])) } -onMounted(() => { load(); timer = setInterval(load, 30_000) }) + +function hasRelevantChange(nextStates) { + return [...nextStates].some(([meetingId, state]) => previousStates.get(meetingId) !== state) +} + +async function playPing() { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext + if (!AudioContext) return + audioContext ||= new AudioContext() + await audioContext.resume() + const oscillator = audioContext.createOscillator() + const gain = audioContext.createGain() + oscillator.type = 'sine' + oscillator.frequency.setValueAtTime(880, audioContext.currentTime) + gain.gain.setValueAtTime(0.0001, audioContext.currentTime) + gain.gain.exponentialRampToValueAtTime(0.16, audioContext.currentTime + 0.01) + gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + 0.17) + oscillator.connect(gain).connect(audioContext.destination) + oscillator.start() + oscillator.stop(audioContext.currentTime + 0.18) + } catch { + soundEnabled.value = false + } +} + +function toggleSound() { + soundEnabled.value = !soundEnabled.value + localStorage.setItem('live-ticker-sound-enabled', String(soundEnabled.value)) + if (soundEnabled.value) playPing() +} + +async function load() { + if (!isInternal.value) { + matches.value = [] + tickerLoaded = false + previousStates = new Map() + return + } + try { + const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || [] + const nextStates = stateByMeeting(nextMatches) + if (tickerLoaded && soundEnabled.value && hasRelevantChange(nextStates)) playPing() + previousStates = nextStates + tickerLoaded = true + matches.value = nextMatches + } catch { matches.value = [] } +} +onMounted(() => { + soundEnabled.value = localStorage.getItem('live-ticker-sound-enabled') === 'true' + load() + timer = setInterval(load, 30_000) +}) onUnmounted(() => clearInterval(timer)) watch(isInternal, load) diff --git a/package.json b/package.json index bd5d076..7cf38d3 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harheimertc-website", - "version": "1.8.13", + "version": "1.9.0", "description": "Moderne Webseite für den Harheimer Tischtennis Club", "private": true, "type": "module", diff --git a/server/utils/live-ticker.js b/server/utils/live-ticker.js index 5240114..412a785 100644 --- a/server/utils/live-ticker.js +++ b/server/utils/live-ticker.js @@ -16,6 +16,11 @@ function score(data) { return `${home ?? 0}:${away ?? 0}` } +function completedTime(data, fallback) { + const timestamp = Date.parse(data.end_date || data.entry_date || '') + return Number.isFinite(timestamp) ? timestamp : fallback +} + export async function refreshLiveTicker(now = new Date()) { const season = getCurrentSeasonSlug(now) const { data } = await readSpielplanData({ season }) @@ -37,7 +42,7 @@ export async function refreshLiveTicker(now = new Date()) { const payload = await response.json() const report = payload?.data if (!report) throw new Error('Keine Spieldaten erhalten') - const completedAt = report.is_completed ? nowMs : null + const completedAt = report.is_completed ? completedTime(report, nowMs) : null const previous = cache.get(meetingId) cache.set(meetingId, { meetingId,