Files
harheimertc/components/LiveTickerBanner.vue
Torsten Schulz (local) 472bec4519
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
fix: show live ticker across app and website and suppress review emails
2026-10-29 21:49:51 +01:00

192 lines
6.0 KiB
Vue

<template>
<section
v-if="authStore.isLoggedIn && matches.length"
class="relative z-40 w-full shrink-0 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 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>
<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="paused"
:aria-label="paused ? 'Laufschrift fortsetzen' : 'Laufschrift anhalten'"
@click="paused = !paused"
>
{{ paused ? '▶' : 'Ⅱ' }}
</button>
<div
class="ticker-viewport min-w-0 flex-1"
:class="{ 'ticker-paused': paused }"
>
<div
ref="tickerTrack"
class="ticker-track"
:style="{ '--ticker-duration': `${tickerDuration}s` }"
>
<div
v-for="copy in 2"
:key="copy"
class="ticker-group"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<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>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useAuthStore } from '~/stores/auth'
const authStore = useAuthStore()
const matches = ref([])
const soundEnabled = ref(false)
const paused = ref(false)
const tickerTrack = ref(null)
const tickerDuration = ref(30)
let timer
let audioContext
let tickerLoaded = false
let previousStates = new Map()
// Two equal copies let the track wrap without a visible jump. ResizeObserver
// keeps the speed at 36 px/s when results, fonts or the viewport change.
watch(tickerTrack, (track, _previous, onCleanup) => {
if (!track) return
const observer = new ResizeObserver(() => {
tickerDuration.value = Math.max(1, track.getBoundingClientRect().width / 2 / 36)
})
observer.observe(track)
onCleanup(() => observer.disconnect())
}, { flush: 'post' })
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 (!authStore.isLoggedIn) {
matches.value = []
tickerLoaded = false
previousStates = new Map()
return
}
try {
const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || []
if (!authStore.isLoggedIn) return
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(() => authStore.isLoggedIn, load)
</script>
<style scoped>
.ticker-viewport {
container-type: inline-size;
overflow: hidden;
}
.ticker-track {
display: flex;
width: max-content;
animation: ticker-scroll var(--ticker-duration) linear infinite;
will-change: transform;
}
.ticker-group {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2rem;
min-width: 100cqw;
padding-right: 2rem;
}
.ticker-paused .ticker-track {
animation-play-state: paused;
}
@media (hover: hover) {
.ticker-viewport:hover .ticker-track { animation-play-state: paused; }
}
@keyframes ticker-scroll {
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.ticker-viewport { overflow-x: auto; }
.ticker-track { animation: none; will-change: auto; }
.ticker-group[aria-hidden="true"] { display: none; }
}
</style>