Run one slideshow timer, not two, and honour each slide's own duration

A site added two slides to the lobby display and it never changed between them.

onMounted awaited fetchSlides, which starts the slideshow itself once it has
more than one slide, and then started it AGAIN unconditionally. Two timer chains
ran, and the second assignment to slideTimer lost the handle to the first, so
nothing could ever cancel it. Both fired about ten seconds later, milliseconds
apart, and each advanced one slide.

With exactly two slides that is 0 -> 1 -> 0 every cycle: the display looked
frozen. With three or more it advanced by two and merely skipped one, which is
why this survived so long - and why adding a third slide would have appeared to
"fix" it.

onMounted no longer starts it; fetchSlides owns that. scheduleNextSlide also
cancels any pending timer before setting a new one, so a future double-call
replaces the chain rather than leaking an untracked one.

While here: the feed has always sent a per-slide duration and the display
ignored it, hardcoding ten seconds, so a slide set to hold for a minute changed
after ten. It now uses the slide's own value, and the progress bar animates over
that same duration instead of finishing early and sitting full.
This commit is contained in:
cproudlock
2026-08-05 13:16:41 -04:00
parent ead5bd8f58
commit b44108f70c

View File

@@ -1,191 +1,207 @@
<template> <template>
<div class="tv-dashboard"> <div class="tv-dashboard">
<div class="slideshow-container"> <div class="slideshow-container">
<div <div
v-for="(slide, idx) in slides" v-for="(slide, idx) in slides"
:key="slide.filename" :key="slide.filename"
class="slide" class="slide"
:class="{ active: idx === currentSlide }" :class="{ active: idx === currentSlide }"
> >
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" /> <img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
</div> </div>
<div v-if="error" class="error-message"> <div v-if="error" class="error-message">
<h2>Display Error</h2> <h2>Display Error</h2>
<p>{{ error }}</p> <p>{{ error }}</p>
</div> </div>
<div v-if="!slides.length && !error" class="error-message"> <div v-if="!slides.length && !error" class="error-message">
<h2>No Slides</h2> <h2>No Slides</h2>
<p>No slides configured for display</p> <p>No slides configured for display</p>
</div> </div>
</div> </div>
<div class="progress-bar" :style="progressStyle"></div> <div class="progress-bar" :style="progressStyle"></div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import api from '@/api' import api from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
// Which slide surface this display renders: 'lobby' (default) or 'shopfloor' // Which slide surface this display renders: 'lobby' (default) or 'shopfloor'
// (the screensaver). Set by the route meta (/screensaver) or a ?surface= query. // (the screensaver). Set by the route meta (/screensaver) or a ?surface= query.
const route = useRoute() const route = useRoute()
const VALID_SURFACES = ['lobby', 'shopfloor'] const VALID_SURFACES = ['lobby', 'shopfloor']
const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface) const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface)
|| (VALID_SURFACES.includes(route.query?.surface) && route.query.surface) || (VALID_SURFACES.includes(route.query?.surface) && route.query.surface)
|| 'lobby' || 'lobby'
const INTERVAL = 10 // seconds between slides const INTERVAL = 10 // seconds between slides
const slides = ref([]) const slides = ref([])
const basePath = ref('/static/slides/') const basePath = ref('/static/slides/')
const currentSlide = ref(0) const currentSlide = ref(0)
const error = ref('') const error = ref('')
const progress = ref(0) const progress = ref(0)
let slideTimer = null let slideTimer = null
let progressTimer = null let progressTimer = null
let refreshTimer = null let refreshTimer = null
const progressStyle = computed(() => ({ const progressStyle = computed(() => ({
width: `${progress.value}%`, width: `${progress.value}%`,
transition: progress.value === 0 ? 'none' : `width ${INTERVAL}s linear` // 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 () => {
await fetchSlides() onMounted(async () => {
// fetchSlides starts the slideshow itself once it has more than one slide.
// Start slideshow if we have slides // Starting it again here ran TWO timer chains at once, and the second
if (slides.value.length > 1) { // assignment to slideTimer lost the handle to the first so nothing could stop
startSlideshow() // 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) // Refresh slide list every 60 seconds
}) refreshTimer = setInterval(fetchSlides, 60000)
})
onUnmounted(() => {
if (slideTimer) clearTimeout(slideTimer) onUnmounted(() => {
if (progressTimer) clearTimeout(progressTimer) if (slideTimer) clearTimeout(slideTimer)
if (refreshTimer) clearInterval(refreshTimer) if (progressTimer) clearTimeout(progressTimer)
}) if (refreshTimer) clearInterval(refreshTimer)
})
async function fetchSlides() {
try { async function fetchSlides() {
// Flat feed shape: { success, surface, basepath, interval, slides:[...] } try {
const response = await api.get('/slides/feed', { params: { surface } }) // Flat feed shape: { success, surface, basepath, interval, slides:[...] }
const data = response.data const response = await api.get('/slides/feed', { params: { surface } })
const data = response.data
if (data.success && data.slides?.length > 0) {
slides.value = data.slides if (data.success && data.slides?.length > 0) {
basePath.value = data.basepath || `/api/slides/img/${surface}/` slides.value = data.slides
error.value = '' basePath.value = data.basepath || `/api/slides/img/${surface}/`
error.value = ''
// Restart slideshow if slides changed
if (slides.value.length > 1 && !slideTimer) { // Restart slideshow if slides changed
startSlideshow() if (slides.value.length > 1 && !slideTimer) {
} startSlideshow()
} else { }
slides.value = [] } else {
error.value = 'No slides configured' slides.value = []
} error.value = 'No slides configured'
} catch (err) { }
console.error('Error fetching slides:', err) } catch (err) {
error.value = 'Unable to load slides' console.error('Error fetching slides:', err)
} error.value = 'Unable to load slides'
} }
}
function startSlideshow() {
scheduleNextSlide() function startSlideshow() {
} scheduleNextSlide()
}
function scheduleNextSlide() {
// Reset progress bar function scheduleNextSlide() {
progress.value = 0 // Cancel anything already pending. Belt and braces after the double-start
// bug above: any future caller that schedules twice now replaces the timer
// Start progress animation after a small delay // rather than leaking an untracked one.
progressTimer = setTimeout(() => { if (slideTimer) { clearTimeout(slideTimer); slideTimer = null }
progress.value = 100 if (progressTimer) { clearTimeout(progressTimer); progressTimer = null }
}, 50)
// Reset progress bar
// Schedule next slide progress.value = 0
slideTimer = setTimeout(() => {
nextSlide() // Start progress animation after a small delay
scheduleNextSlide() progressTimer = setTimeout(() => {
}, INTERVAL * 1000) progress.value = 100
} }, 50)
function nextSlide() { // The feed sends a per-slide duration and this used to ignore it, so a slide
if (slides.value.length === 0) return // set to hold for a minute still changed after ten seconds.
currentSlide.value = (currentSlide.value + 1) % slides.value.length const seconds = currentSlideSeconds()
}
</script> slideTimer = setTimeout(() => {
nextSlide()
<style scoped> scheduleNextSlide()
.tv-dashboard { }, seconds * 1000)
background: #000; }
width: 100vw;
height: 100vh; function currentSlideSeconds() {
margin: 0; const slide = slides.value[currentSlide.value]
padding: 0; const seconds = Number(slide && slide.seconds)
overflow: hidden; return seconds > 0 ? seconds : INTERVAL
} }
.slideshow-container { function nextSlide() {
position: relative; if (slides.value.length === 0) return
width: 100%; currentSlide.value = (currentSlide.value + 1) % slides.value.length
height: 100%; }
} </script>
.slide { <style scoped>
position: absolute; .tv-dashboard {
top: 0; background: #000;
left: 0; width: 100vw;
width: 100%; height: 100vh;
height: 100%; margin: 0;
opacity: 0; padding: 0;
transition: opacity 1s ease-in-out; overflow: hidden;
display: flex; }
align-items: center;
justify-content: center; .slideshow-container {
} position: relative;
width: 100%;
.slide.active { height: 100%;
opacity: 1; }
}
.slide {
.slide img { position: absolute;
width: 100%; top: 0;
height: 100%; left: 0;
object-fit: contain; width: 100%;
} height: 100%;
opacity: 0;
.progress-bar { transition: opacity 1s ease-in-out;
position: fixed; display: flex;
bottom: 0; align-items: center;
left: 0; justify-content: center;
height: 4px; }
background: #4181ff;
z-index: 100; .slide.active {
} opacity: 1;
}
.error-message {
position: fixed; .slide img {
top: 50%; width: 100%;
left: 50%; height: 100%;
transform: translate(-50%, -50%); object-fit: contain;
text-align: center; }
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; .progress-bar {
font-size: 24px; position: fixed;
} bottom: 0;
left: 0;
.error-message h2 { height: 4px;
color: #ff4444; background: #4181ff;
margin-bottom: 20px; z-index: 100;
} }
</style>
.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>