Files
shopdb-flask/plugins/slides/frontend/views/TVDashboard.vue
cproudlock b44108f70c 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.
2026-08-05 13:16:41 -04:00

208 lines
5.2 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)
})
onUnmounted(() => {
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
}
</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>