192 lines
6.0 KiB
Vue
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>
|