feat: implement LiveTicker functionality with sound notifications and UI updates
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m11s

This commit is contained in:
Torsten Schulz (local)
2026-10-22 23:57:22 +02:00
parent 6b9198ac41
commit 6369249f40
10 changed files with 250 additions and 8 deletions

View File

@@ -37,6 +37,22 @@ data class SpielplanResponse(
val season: String? = null, val season: String? = null,
val seasons: List<SeasonDto> = emptyList(), val seasons: List<SeasonDto> = emptyList(),
) )
data class LiveTickerResponse(
val matches: List<LiveTickerMatchDto> = 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 SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse( data class MannschaftenSeasonsResponse(
val success: Boolean = false, val success: Boolean = false,
@@ -726,6 +742,9 @@ interface ApiService {
@GET("/api/mitgliederbereich/qttr") @GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse> suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/mitgliederbereich/live-ticker")
suspend fun liveTicker(): Response<LiveTickerResponse>
@GET("/api/members") @GET("/api/members")
suspend fun members(): Response<MembersResponse> suspend fun members(): Response<MembersResponse>

View File

@@ -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<List<LiveTickerMatchDto>> = runCatching {
retryOnNetworkFailure {
val response = api.liveTicker()
if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.")
response.body()?.matches ?: emptyList()
}
}
}

View File

@@ -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<LiveTickerMatchDto>, loaded: Boolean) {
var observedLoadedState by remember { mutableStateOf(false) }
var previousStates by remember { mutableStateOf<Map<String, String>>(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,
)
}
}
}
}

View File

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

View File

@@ -25,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.LiveTickerBanner
@Composable @Composable
fun NavGraph( fun NavGraph(
@@ -44,6 +45,11 @@ fun NavGraph(
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession() 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()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -72,6 +78,10 @@ fun NavGraph(
navigationState = navigationState, navigationState = navigationState,
) )
} }
LiveTickerBanner(
matches = navigationState.liveTickerMatches,
loaded = navigationState.liveTickerLoaded,
)
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,

View File

@@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.LiveTickerMatchDto
import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.LiveTickerRepository
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -22,6 +27,8 @@ data class NavigationUiState(
val loggedIn: Boolean = false, val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(), val roles: Set<String> = emptySet(),
val connectionNote: String? = null, val connectionNote: String? = null,
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
val liveTickerLoaded: Boolean = false,
) { ) {
val isAdmin: Boolean get() = "admin" in roles val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") } 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 authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor, private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository, private val pushTokenRepository: PushTokenRepository,
private val liveTickerRepository: LiveTickerRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
private var tickerJob: Job? = null
private var tickerActive = false
init { init {
loadNavigationData() loadNavigationData()
@@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor(
loggedIn = loggedIn, loggedIn = loggedIn,
roles = status.navigationRoles(), roles = status.navigationRoles(),
connectionNote = null, connectionNote = null,
liveTickerMatches = _state.value.liveTickerMatches,
liveTickerLoaded = _state.value.liveTickerLoaded,
) )
if (loggedIn) registerPushToken() 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() { private fun registerPushToken() {
viewModelScope.launch { viewModelScope.launch {
pushTokenRepository.registerCurrentDevice() pushTokenRepository.registerCurrentDevice()
@@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor(
} }
fun logout(onComplete: () -> Unit = {}) { fun logout(onComplete: () -> Unit = {}) {
setLiveTickerActive(false)
viewModelScope.launch { viewModelScope.launch {
loginRepository.logout() loginRepository.logout()
_state.value = _state.value.copy( _state.value = _state.value.copy(

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=37 ANDROID_VERSION_CODE=38
ANDROID_VERSION_NAME=0.10.1 ANDROID_VERSION_NAME=0.11.0
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -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" class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
> >
<div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6"> <div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6">
<button
type="button"
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
:aria-pressed="soundEnabled"
@click="toggleSound"
>
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
</button>
<div <div
v-for="match in matches" v-for="match in matches"
:key="match.meetingId" :key="match.meetingId"
@@ -32,13 +40,72 @@ import { useAuthStore } from '~/stores/auth'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const matches = ref([]) const matches = ref([])
const soundEnabled = ref(false)
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms'))) const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
let timer let timer
async function load() { let audioContext
if (!isInternal.value) { matches.value = []; return } let tickerLoaded = false
try { matches.value = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || [] } catch { matches.value = [] } 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)) onUnmounted(() => clearInterval(timer))
watch(isInternal, load) watch(isInternal, load)
</script> </script>

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.13", "version": "1.9.0",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@@ -16,6 +16,11 @@ function score(data) {
return `${home ?? 0}:${away ?? 0}` 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()) { export async function refreshLiveTicker(now = new Date()) {
const season = getCurrentSeasonSlug(now) const season = getCurrentSeasonSlug(now)
const { data } = await readSpielplanData({ season }) const { data } = await readSpielplanData({ season })
@@ -37,7 +42,7 @@ export async function refreshLiveTicker(now = new Date()) {
const payload = await response.json() const payload = await response.json()
const report = payload?.data const report = payload?.data
if (!report) throw new Error('Keine Spieldaten erhalten') 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) const previous = cache.get(meetingId)
cache.set(meetingId, { cache.set(meetingId, {
meetingId, meetingId,