112 lines
3.8 KiB
Vue
112 lines
3.8 KiB
Vue
<template>
|
|
<section
|
|
v-if="isInternal && matches.length"
|
|
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"
|
|
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
|
|
>
|
|
<span v-if="match.completed">✓ beendet</span>
|
|
<span
|
|
v-else-if="match.hasLiveData"
|
|
class="text-red-700"
|
|
>● LIVE</span>
|
|
<span
|
|
v-else
|
|
class="text-gray-600"
|
|
>◌ Keine Live-Daten</span>
|
|
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
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
|
|
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}`
|
|
]))
|
|
}
|
|
|
|
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>
|