Somebody standing at a display who wants to hold a slide, or go back to one that has just passed, had to wait for the whole rotation to come round again. Left and right arrows step back and forward; PageUp and PageDown do the same, so a presenter remote works without being told about it. The listener is on window rather than an element because a display has nothing focused. Stepping restarts the rotation timer instead of leaving it running. Advancing by hand and then having it move again a second later, because the existing timer was nearly up, reads as the display ignoring the keypress. Ignored entirely when there is less than one slide to move to.
239 lines
6.4 KiB
Vue
239 lines
6.4 KiB
Vue
<template>
|
|
<div class="tv-dashboard">
|
|
<div class="slideshow-container">
|
|
<div
|
|
v-for="(slide, idx) in slides"
|
|
:key="slide.filename"
|
|
class="slide"
|
|
:class="{ active: idx === currentSlide }"
|
|
>
|
|
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">
|
|
<h2>Display Error</h2>
|
|
<p>{{ error }}</p>
|
|
</div>
|
|
|
|
<div v-if="!slides.length && !error" class="error-message">
|
|
<h2>No Slides</h2>
|
|
<p>No slides configured for display</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-bar" :style="progressStyle"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import api from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
|
|
// Which slide surface this display renders: 'lobby' (default) or 'shopfloor'
|
|
// (the screensaver). Set by the route meta (/screensaver) or a ?surface= query.
|
|
const route = useRoute()
|
|
const VALID_SURFACES = ['lobby', 'shopfloor']
|
|
const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface)
|
|
|| (VALID_SURFACES.includes(route.query?.surface) && route.query.surface)
|
|
|| 'lobby'
|
|
|
|
const INTERVAL = 10 // seconds between slides
|
|
|
|
const slides = ref([])
|
|
const basePath = ref('/static/slides/')
|
|
const currentSlide = ref(0)
|
|
const error = ref('')
|
|
const progress = ref(0)
|
|
|
|
let slideTimer = null
|
|
let progressTimer = null
|
|
let refreshTimer = null
|
|
|
|
const progressStyle = computed(() => ({
|
|
width: `${progress.value}%`,
|
|
// Matches the slide's own duration, or the bar finishes early and sits full.
|
|
transition: progress.value === 0 ? 'none' : `width ${currentSlideSeconds()}s linear`
|
|
}))
|
|
|
|
onMounted(async () => {
|
|
// fetchSlides starts the slideshow itself once it has more than one slide.
|
|
// Starting it again here ran TWO timer chains at once, and the second
|
|
// assignment to slideTimer lost the handle to the first so nothing could stop
|
|
// it. Both chains called nextSlide a few milliseconds apart, so with exactly
|
|
// two slides the display advanced 0 -> 1 -> 0 every cycle and looked frozen.
|
|
await fetchSlides()
|
|
|
|
// Refresh slide list every 60 seconds
|
|
refreshTimer = setInterval(fetchSlides, 60000)
|
|
|
|
// On window, not on an element: the display has nothing focused, and a
|
|
// presenter remote sends the same keys as a keyboard.
|
|
window.addEventListener('keydown', onKeydown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', onKeydown)
|
|
if (slideTimer) clearTimeout(slideTimer)
|
|
if (progressTimer) clearTimeout(progressTimer)
|
|
if (refreshTimer) clearInterval(refreshTimer)
|
|
})
|
|
|
|
async function fetchSlides() {
|
|
try {
|
|
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
|
|
const response = await api.get('/slides/feed', { params: { surface } })
|
|
const data = response.data
|
|
|
|
if (data.success && data.slides?.length > 0) {
|
|
slides.value = data.slides
|
|
basePath.value = data.basepath || `/api/slides/img/${surface}/`
|
|
error.value = ''
|
|
|
|
// Restart slideshow if slides changed
|
|
if (slides.value.length > 1 && !slideTimer) {
|
|
startSlideshow()
|
|
}
|
|
} else {
|
|
slides.value = []
|
|
error.value = 'No slides configured'
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching slides:', err)
|
|
error.value = 'Unable to load slides'
|
|
}
|
|
}
|
|
|
|
function startSlideshow() {
|
|
scheduleNextSlide()
|
|
}
|
|
|
|
function scheduleNextSlide() {
|
|
// Cancel anything already pending. Belt and braces after the double-start
|
|
// bug above: any future caller that schedules twice now replaces the timer
|
|
// rather than leaking an untracked one.
|
|
if (slideTimer) { clearTimeout(slideTimer); slideTimer = null }
|
|
if (progressTimer) { clearTimeout(progressTimer); progressTimer = null }
|
|
|
|
// Reset progress bar
|
|
progress.value = 0
|
|
|
|
// Start progress animation after a small delay
|
|
progressTimer = setTimeout(() => {
|
|
progress.value = 100
|
|
}, 50)
|
|
|
|
// The feed sends a per-slide duration and this used to ignore it, so a slide
|
|
// set to hold for a minute still changed after ten seconds.
|
|
const seconds = currentSlideSeconds()
|
|
|
|
slideTimer = setTimeout(() => {
|
|
nextSlide()
|
|
scheduleNextSlide()
|
|
}, seconds * 1000)
|
|
}
|
|
|
|
function currentSlideSeconds() {
|
|
const slide = slides.value[currentSlide.value]
|
|
const seconds = Number(slide && slide.seconds)
|
|
return seconds > 0 ? seconds : INTERVAL
|
|
}
|
|
|
|
function nextSlide() {
|
|
if (slides.value.length === 0) return
|
|
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
|
}
|
|
|
|
function previousSlide() {
|
|
if (slides.value.length === 0) return
|
|
const count = slides.value.length
|
|
currentSlide.value = (currentSlide.value - 1 + count) % count
|
|
}
|
|
|
|
// Left and right arrows step through the slides by hand. Someone standing at
|
|
// the display wanting to hold a slide, or go back to one that has just passed,
|
|
// otherwise has to wait for the whole rotation to come round again.
|
|
//
|
|
// Stepping RESTARTS the timer rather than leaving it running: advancing
|
|
// manually and then having it move again a second later, because the existing
|
|
// timer was most of the way through, reads as the display ignoring you.
|
|
function onKeydown(event) {
|
|
if (slides.value.length < 2) return
|
|
if (event.key === 'ArrowRight' || event.key === 'PageDown') {
|
|
event.preventDefault()
|
|
nextSlide()
|
|
scheduleNextSlide()
|
|
} else if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
|
|
event.preventDefault()
|
|
previousSlide()
|
|
scheduleNextSlide()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.tv-dashboard {
|
|
background: #000;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.slideshow-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.slide {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0;
|
|
transition: opacity 1s ease-in-out;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.slide.active {
|
|
opacity: 1;
|
|
}
|
|
|
|
.slide img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.progress-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
height: 4px;
|
|
background: #4181ff;
|
|
z-index: 100;
|
|
}
|
|
|
|
.error-message {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
color: #fff;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
font-size: 24px;
|
|
}
|
|
|
|
.error-message h2 {
|
|
color: #ff4444;
|
|
margin-bottom: 20px;
|
|
}
|
|
</style>
|