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

@@ -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"
>
<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
v-for="match in matches"
:key="match.meetingId"
@@ -32,13 +40,72 @@ import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const matches = ref([])
const soundEnabled = ref(false)
const isInternal = computed(() => 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)
</script>