diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a3b0151..4d0b19a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ All notable changes to this project will be documented in this file. - Optimized release data fetching. - Optimized list loading. - Removed fixture length check from test. +- Introduced two hooks (useBaseSlideExecution, useMultipleEntrySlideExecution) that are used in the templates in place + of BaseSlideExecution in fixed duration slides and manual iteration over entries in slides that iterate through + elements before calling slideDone. +- Fixed issue with Slideshow animations that would be locked to one type. +- Fixed sorting in calendar "multiple" layout. +- Fixed video progression issues. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/README.md b/README.md index b3aab4b02..8d7164327 100644 --- a/README.md +++ b/README.md @@ -840,7 +840,7 @@ For an example of a custom template see `assets/shared/custom-templates-example/ The slide is responsible for signaling that it is done executing. This is done by calling the slideDone() function. If the slide should just run for X milliseconds then you can use the -BaseSlideExecution class to handle this. See the example for this approach. +`useBaseSlideExecution` hook to handle this. See the example for this approach. ##### Admin Form diff --git a/Taskfile.yml b/Taskfile.yml index 2b7ff4b09..1799d7990 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -202,7 +202,7 @@ tasks: test:frontend-local: desc: "Runs frontend tests from the local machine." cmds: - - BASE_URL="https://display.local.itkdev.dk" npx playwright test + - BASE_URL="https://display.local.itkdev.dk" npx playwright test {{.CLI_ARGS}} test:frontend-local-ui: desc: "Runs frontend tests from the local machine in UI mode." diff --git a/assets/shared/custom-templates-example/custom-template-example.jsx b/assets/shared/custom-templates-example/custom-template-example.jsx index 0ea8a45fd..3e3132221 100644 --- a/assets/shared/custom-templates-example/custom-template-example.jsx +++ b/assets/shared/custom-templates-example/custom-template-example.jsx @@ -1,6 +1,5 @@ -import { useEffect } from "react"; import templateConfig from "./custom-template-example.json"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; /** @@ -57,17 +56,7 @@ function CustomTemplateExample({ const { duration = 15000 } = content; const { title = "Default title" } = content; - const slideExecution = new BaseSlideExecution(slide, slideDone); - - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/shared/slide-utils/base-slide-execution.js b/assets/shared/slide-utils/base-slide-execution.js deleted file mode 100644 index cbcdda4ae..000000000 --- a/assets/shared/slide-utils/base-slide-execution.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * BaseSlideExecution. - * - * Slide runs for duration then calls slideDone(). - */ -class BaseSlideExecution { - // Function to call when the slide is done executing. - slideDone; - - // Slide that should be run. - slide; - - // Slide timeout. - slideTimeout = null; - - /** - * Constructor. - * - * @param {object} slide The slide to execute. - * @param {Function} slideDone The function to invoke when execution is done. - */ - constructor(slide, slideDone) { - this.slide = slide; - this.slideDone = slideDone; - } - - /** - * Start execution of slide. - * - * @param {number} duration Slide duration in milliseconds. - */ - start(duration) { - if (this.slideTimeout !== null) { - clearTimeout(this.slideTimeout); - } - - // Wait duration when call slideDone. - this.slideTimeout = setTimeout(() => { - this.slideDone(this.slide); - this.slideTimeout = null; - }, duration); - } - - /** Stops execution timeout. */ - stop() { - if (this.slideTimeout !== null) { - clearTimeout(this.slideTimeout); - this.slideTimeout = null; - } - } -} - -export default BaseSlideExecution; diff --git a/assets/shared/slide-utils/useBaseSlideExecution.js b/assets/shared/slide-utils/useBaseSlideExecution.js new file mode 100644 index 000000000..855604e09 --- /dev/null +++ b/assets/shared/slide-utils/useBaseSlideExecution.js @@ -0,0 +1,40 @@ +import { useEffect, useRef } from "react"; + +/** + * Hook to manage slide execution lifecycle. + * + * Uses refs for slide, slideDone, and duration to avoid stale closures + * while keeping [run] as the only dependency that triggers the timer. + * + * @param {object} options + * @param {object} options.slide The slide object. + * @param {boolean} options.run Whether the slide should run. + * @param {Function} options.slideDone Callback when slide finishes. + * @param {number} options.duration Duration in ms. + */ +function useBaseSlideExecution({ slide, run, slideDone, duration }) { + const slideRef = useRef(slide); + const slideDoneRef = useRef(slideDone); + const durationRef = useRef(duration); + + slideRef.current = slide; + slideDoneRef.current = slideDone; + durationRef.current = duration; + + useEffect(() => { + if (!run) return; + + const safeDuration = + Number.isFinite(durationRef.current) && durationRef.current > 0 + ? durationRef.current + : 15000; + + const timeoutId = setTimeout(() => { + slideDoneRef.current(slideRef.current); + }, safeDuration); + + return () => clearTimeout(timeoutId); + }, [run]); +} + +export default useBaseSlideExecution; diff --git a/assets/shared/slide-utils/useMultipleEntrySlideExecution.js b/assets/shared/slide-utils/useMultipleEntrySlideExecution.js new file mode 100644 index 000000000..995996a52 --- /dev/null +++ b/assets/shared/slide-utils/useMultipleEntrySlideExecution.js @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Hook to manage slide execution for templates that cycle through + * multiple entries (RSS feeds, news feeds, slideshows, etc.). + * + * Uses refs to avoid stale closures while keeping [run] as the + * only dependency that triggers the cycling. + * + * @param {object} options + * @param {Array} options.entries Array of entries to cycle through. + * @param {boolean} options.run Whether the slide should run. + * @param {object} options.slide The slide object. + * @param {Function} options.slideDone Callback when cycling completes. + * @param {number} options.entryDuration Duration per entry in ms. + */ +function useMultipleEntrySlideExecution({ + entries, + run, + slide, + slideDone, + entryDuration, +}) { + const [entryIndex, setEntryIndex] = useState(0); + const [currentEntry, setCurrentEntry] = useState(null); + + // Refs to avoid stale closures (same pattern as useBaseSlideExecution) + const slideRef = useRef(slide); + const slideDoneRef = useRef(slideDone); + const entriesRef = useRef(entries); + const entryDurationRef = useRef(entryDuration); + + slideRef.current = slide; + slideDoneRef.current = slideDone; + entriesRef.current = entries; + entryDurationRef.current = entryDuration; + + useEffect(() => { + if (!run || !entriesRef.current?.length) return; + + let timeoutId = null; + let stopped = false; + + const showEntry = (index) => { + if (stopped) return; + + if (index >= entriesRef.current.length) { + slideDoneRef.current(slideRef.current); + return; + } + + setEntryIndex(index); + setCurrentEntry(entriesRef.current[index]); + + const safeDuration = + Number.isFinite(entryDurationRef.current) && + entryDurationRef.current > 0 + ? entryDurationRef.current + : 15000; + + timeoutId = setTimeout(() => { + showEntry(index + 1); + }, safeDuration); + }; + + showEntry(0); + + return () => { + stopped = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + }; + }, [run]); + + return { currentEntry, entryIndex }; +} + +export default useMultipleEntrySlideExecution; diff --git a/assets/shared/templates/book-review.jsx b/assets/shared/templates/book-review.jsx index bfc70f43f..37c324000 100644 --- a/assets/shared/templates/book-review.jsx +++ b/assets/shared/templates/book-review.jsx @@ -1,7 +1,6 @@ -import { useEffect } from "react"; import parse from "html-react-parser"; import DOMPurify from "dompurify"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getFirstMediaUrlFromField, ThemeStyles, @@ -61,17 +60,7 @@ function BookReview({ slide, content, run, slideDone, executionId }) { ? { backgroundImage: `url("${bookImageUrl}")` } : ""; - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/shared/templates/brnd.jsx b/assets/shared/templates/brnd.jsx index cb84d7687..eea761428 100644 --- a/assets/shared/templates/brnd.jsx +++ b/assets/shared/templates/brnd.jsx @@ -2,7 +2,7 @@ import React, { useEffect, Fragment, useState } from "react"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { FormattedMessage, IntlProvider } from "react-intl"; -import BaseSlideExecution from "../slide-utils/base-slide-execution"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import da from "./brnd/lang/da.json"; import { getFirstMediaUrlFromField, @@ -64,17 +64,7 @@ function Brnd({ slide, content, run, slideDone, executionId }) { rootStyle["--bg-image"] = `url("${imageUrl}")`; } - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); /** Imports language strings, sets localized formats. */ useEffect(() => { diff --git a/assets/shared/templates/calendar.jsx b/assets/shared/templates/calendar.jsx index df3e2883d..727245fa3 100644 --- a/assets/shared/templates/calendar.jsx +++ b/assets/shared/templates/calendar.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { FormattedMessage, IntlProvider } from "react-intl"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import da from "./calendar/lang/da.json"; import { getFirstMediaUrlFromField, @@ -48,7 +48,7 @@ function renderSlide(slide, run, slideDone) { * @returns {JSX.Element} The component. */ function Calendar({ slide, content, run, slideDone, executionId }) { - const [translations, setTranslations] = useState(); + const [translations, setTranslations] = useState(da); const { layout = "multiple", @@ -67,23 +67,11 @@ function Calendar({ slide, content, run, slideDone, executionId }) { rootStyle["--bg-image"] = `url("${imageUrl}")`; } - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } + useBaseSlideExecution({ slide, run, slideDone, duration }); - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); - - /** Imports language strings, sets localized formats. */ + /** Sets localized formats. */ useEffect(() => { dayjs.extend(localizedFormat); - - setTranslations(da); }, []); const getTitle = (eventTitle) => { diff --git a/assets/shared/templates/calendar/calendar-multiple.jsx b/assets/shared/templates/calendar/calendar-multiple.jsx index d1ceb5d9d..b23c8bdf6 100644 --- a/assets/shared/templates/calendar/calendar-multiple.jsx +++ b/assets/shared/templates/calendar/calendar-multiple.jsx @@ -70,7 +70,7 @@ function CalendarMultiple({ return e.endTime > now.unix() && startDate.date() === now.date(); }) - .sort((a, b) => a - b); + .sort((a, b) => a.startTime - b.startTime); }; useEffect(() => { diff --git a/assets/shared/templates/contacts.jsx b/assets/shared/templates/contacts.jsx index e7ef621c8..ac3148cda 100644 --- a/assets/shared/templates/contacts.jsx +++ b/assets/shared/templates/contacts.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { IntlProvider, FormattedMessage } from "react-intl"; import styled from "styled-components"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import da from "./contacts/lang/da.json"; import { getFirstMediaUrlFromField, @@ -72,17 +72,7 @@ function Contacts({ slide, content, run, slideDone, executionId }) { setTranslations(da); }, []); - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( diff --git a/assets/shared/templates/iframe.jsx b/assets/shared/templates/iframe.jsx index 862c41de9..c6285298e 100644 --- a/assets/shared/templates/iframe.jsx +++ b/assets/shared/templates/iframe.jsx @@ -1,5 +1,4 @@ -import { useEffect } from "react"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; import "../slide-utils/global-styles.css"; import templateConfig from "./iframe.json"; @@ -38,17 +37,7 @@ function renderSlide(slide, run, slideDone) { function IFrame({ slide, content, run, slideDone, executionId }) { const { source, duration = 15000 } = content; - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index 6f6b33baa..3d9ec992d 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -2,7 +2,7 @@ import { createRef, useEffect, useRef, useState } from "react"; import parse from "html-react-parser"; import DOMPurify from "dompurify"; import { CSSTransition, TransitionGroup } from "react-transition-group"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getAllMediaUrlsFromField, ThemeStyles, @@ -43,7 +43,9 @@ function renderSlide(slide, run, slideDone) { function ImageText({ slide, content, run, slideDone, executionId }) { const imageTimeoutRef = useRef(); const imagesRef = useRef([]); + const durationRef = useRef(); const [images, setImages] = useState([]); + imagesRef.current = images; const [currentImage, setCurrentImage] = useState(); const logo = slide?.theme?.logo; const { @@ -78,16 +80,16 @@ function ImageText({ slide, content, run, slideDone, executionId }) { halfSize, fontSize, shadow, - } = content || {}; + } = content; let boxClasses = "box"; // Styling objects - const rootStyle = {}; const imageTextStyle = {}; // Content from content const { title, text, textColor, boxColor, duration = 15000 } = content; + durationRef.current = duration; const sanitizedText = DOMPurify.sanitize(text); @@ -95,7 +97,7 @@ function ImageText({ slide, content, run, slideDone, executionId }) { const displaySeparator = separator && !reversed; // Set background image. - if (!(images?.length > 0)) { + if (images.length === 0) { boxClasses = `${boxClasses} full-screen`; } @@ -141,7 +143,7 @@ function ImageText({ slide, content, run, slideDone, executionId }) { if (newIndex < currentImages.length - 1) { imageTimeoutRef.current = setTimeout( () => changeImage(newIndex + 1), - duration / currentImages.length, + durationRef.current / currentImages.length, ); } } @@ -160,20 +162,21 @@ function ImageText({ slide, content, run, slideDone, executionId }) { nodeRef: createRef(), })); - imagesRef.current = newImages; - setImages(newImages); } else { - imagesRef.current = []; setImages([]); } } - }, [slide]); + }, [slide, content.image]); - const startTheShow = () => { + const clearImageTimeout = () => { if (imageTimeoutRef.current) { clearTimeout(imageTimeoutRef.current); } + }; + + const startTheShow = () => { + clearImageTimeout(); const currentImages = imagesRef.current; @@ -193,27 +196,20 @@ function ImageText({ slide, content, run, slideDone, executionId }) { } }, [images]); - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); + useBaseSlideExecution({ slide, run, slideDone, duration }); useEffect(() => { if (run) { startTheShow(); - slideExecution.start(duration); + return clearImageTimeout; } - return function cleanup() { - slideExecution.stop(); - - if (imageTimeoutRef.current) { - clearTimeout(imageTimeoutRef.current); - } - }; + return clearImageTimeout; }, [run]); return ( <> -
+
{currentImage && (
{ - const timer = setTimeout(() => { - const currentIndex = feedData.indexOf(currentPost); - const nextIndex = - (currentIndex + 1) % Math.min(feedData.length, maxEntriesToShow); - - if (nextIndex === 0) { - slideDone(slide); - } else { - setCurrentPost(feedData[nextIndex]); - setShow(true); - } - }, duration); + if (!currentPost) return; + setShow(true); const animationTimer = setTimeout(() => { setShow(false); }, duration - animationDuration); - return function cleanup() { - if (timer !== null) { - clearInterval(timer); - } - if (animationTimer !== null) { - clearInterval(animationTimer); - } - }; + return () => clearTimeout(animationTimer); }, [currentPost]); + // If no content, wait 1 second and continue to next slide. useEffect(() => { - if (run) { - if (feedData?.length > 0) { - setCurrentPost(feedData[0]); - } else { - setTimeout(() => slideDone(slide), 1000); - } + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 1000); } + + return () => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + } + }; }, [run]); const getSanitizedMarkup = (textMarkup) => { diff --git a/assets/shared/templates/news-feed.jsx b/assets/shared/templates/news-feed.jsx index 0fb6fe19e..338a14b5d 100644 --- a/assets/shared/templates/news-feed.jsx +++ b/assets/shared/templates/news-feed.jsx @@ -8,6 +8,7 @@ import { getFirstMediaUrlFromField, ThemeStyles, } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./news-feed/news-feed.scss"; import templateConfig from "./news-feed.json"; @@ -47,12 +48,8 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { dayjs.extend(localizedFormat); dayjs.extend(relativeTime); - const [currentPost, setCurrentPost] = useState(null); - const [posts, setPosts] = useState([]); const [qr, setQr] = useState(null); - const transitionRef = useRef(null); - - const timerRef = useRef(); + const fallbackRef = useRef(null); const { feedData = [], mediaData = {} } = slide; const { @@ -63,77 +60,40 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { } = content; const fallbackImageUrl = getFirstMediaUrlFromField(mediaData, fallbackImage); + const feedEntries = feedData?.entries ?? []; - const duration = entryDuration * 1000; + const { currentEntry: currentPost } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: entryDuration * 1000, + }); - // Setup feed entry switch, if there is more than one post. + // Generate QR code for current post link. useEffect(() => { - if (currentPost) { - timerRef.current = setTimeout(() => { - const currentIndex = posts.indexOf(currentPost); - const nextIndex = (currentIndex + 1) % posts.length; - - if (nextIndex === 0) { - slideDone(slide); - } else { - setCurrentPost(posts[nextIndex]); - } - }, duration); - - if (!currentPost?.link) { - setQr(null); - } else { - QRCode.toDataURL(currentPost.link, { - margin: 0, - color: { - dark: "#000000", - light: "#ffffff00", - }, - }).then((data) => { - setQr(data); - }); - } + if (!currentPost?.link) { + setQr(null); + return; } - - return function cleanup() { - if (timerRef?.current) { - clearInterval(timerRef.current); - } - }; + QRCode.toDataURL(currentPost.link, { + margin: 0, + color: { dark: "#000000", light: "#ffffff00" }, + }).then((data) => setQr(data)); }, [currentPost]); + // If no content, wait 5 seconds and continue to next slide. useEffect(() => { - if (posts.length > 0) { - setCurrentPost(posts[0]); - } - }, [posts]); - - useEffect(() => { - if (feedData?.entries?.length > 0) { - setPosts(feedData.entries); - } else if (!transitionRef.current) { - // If no content, wait 5 seconds and continue to next slide. - transitionRef.current = setTimeout(() => { - slideDone(slide); - }, 5000); - } - }, [feedData]); - - useEffect(() => { - if (run) { - if (posts?.length > 0) { - setCurrentPost(posts[0]); - } + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 5000); } - }, [run]); - useEffect(() => { return () => { - if (transitionRef.current) { - clearInterval(transitionRef.current); + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); } }; - }, []); + }, [run]); const getImageUrl = (post) => { let imageUrl = fallbackImageUrl ?? null; diff --git a/assets/shared/templates/poster.jsx b/assets/shared/templates/poster.jsx index 46501f4e0..564c9d02a 100644 --- a/assets/shared/templates/poster.jsx +++ b/assets/shared/templates/poster.jsx @@ -5,6 +5,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat"; import { IntlProvider, FormattedMessage } from "react-intl"; import da from "./poster/lang/da.json"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./poster/poster.scss"; import templateConfig from "./poster.json"; @@ -42,11 +43,8 @@ function renderSlide(slide, run, slideDone) { */ function Poster({ slide, content, run, slideDone, executionId }) { const [translations, setTranslations] = useState({}); - const [currentEvent, setCurrentEvent] = useState(null); - const [currentIndex, setCurrentIndex] = useState(null); const [show, setShow] = useState(true); - const timerRef = useRef(null); - const animationTimerRef = useRef(null); + const fallbackRef = useRef(null); const logo = slide?.theme?.logo; const { showLogo, mediaContain } = content; @@ -58,6 +56,16 @@ function Poster({ slide, content, run, slideDone, executionId }) { const animationDuration = 500; const { duration = 15000 } = content; // default 15s. + const feedEntries = feedData ?? []; + + const { currentEntry: currentEvent } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: duration, + }); + // Props from currentEvent. const { endDate, @@ -121,75 +129,38 @@ function Poster({ slide, content, run, slideDone, executionId }) { ); }; + // Trigger fade-out animation before entry changes. useEffect(() => { - if (currentEvent) { - setShow(true); - } - }, [currentEvent]); - - // Setup feed entry switch and animation, if there is more than one post. - useEffect(() => { - if (currentIndex === null) { - return; - } - - setCurrentEvent(feedData[currentIndex]); - - const nextIndex = (currentIndex + 1) % feedData.length; + if (!currentEvent) return; - if (nextIndex > 0) { - if (animationTimerRef?.current) { - clearInterval(animationTimerRef.current); - } + setShow(true); + const animationTimer = setTimeout( + () => { + setShow(false); + }, + duration - animationDuration + 50, + ); - animationTimerRef.current = setTimeout( - () => { - setShow(false); - }, - duration - animationDuration + 50, - ); - } + return () => clearTimeout(animationTimer); + }, [currentEvent]); - if (timerRef?.current) { - clearInterval(timerRef.current); + // If no content, wait 1 second and continue to next slide. + useEffect(() => { + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 1000); } - timerRef.current = setTimeout(() => { - if (nextIndex === 0) { - slideDone(slide); - } else { - setCurrentIndex(nextIndex); - } - }, duration); - }, [currentIndex]); - - useEffect(() => { - if (run) { - if (feedData?.length > 0) { - setCurrentIndex(0); - } else { - setTimeout(() => slideDone(slide), 1000); + return () => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); } - } else { - setCurrentEvent(null); - setCurrentIndex(null); - } + }; }, [run]); - // Imports language strings, sets localized formats and sets timer. + // Imports language strings and sets localized formats. useEffect(() => { dayjs.extend(localizedFormat); - setTranslations(da); - - return function cleanup() { - if (timerRef?.current) { - clearInterval(timerRef.current); - } - if (animationTimerRef?.current) { - clearInterval(animationTimerRef.current); - } - }; }, []); return ( diff --git a/assets/shared/templates/rss.jsx b/assets/shared/templates/rss.jsx index f65d62420..52069feb3 100644 --- a/assets/shared/templates/rss.jsx +++ b/assets/shared/templates/rss.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import dayjs from "dayjs"; import localeDa from "dayjs/locale/da"; import localizedFormat from "dayjs/plugin/localizedFormat"; @@ -8,6 +8,7 @@ import { ThemeStyles, } from "../slide-utils/slide-util.jsx"; import GlobalStyles from "../slide-utils/GlobalStyles.js"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "./rss/rss.scss"; import templateConfig from "./rss.json"; @@ -31,6 +32,16 @@ function renderSlide(slide, run, slideDone) { ); } +/** + * Capitalize the datestring, as it starts with the weekday. + * + * @param {string} s The string to capitalize. + * @returns {string} The capitalized string. + */ +const capitalize = (s) => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + /** * RSS component. * @@ -43,63 +54,53 @@ function renderSlide(slide, run, slideDone) { * @returns {JSX.Element} The component. */ function RSS({ slide, content, run, slideDone, executionId }) { - const [entryIndex, setEntryIndex] = useState(0); - const [currentEntry, setCurrentEntry] = useState(null); - const timeoutRef = useRef(null); - - if (!slide?.feed) { - return ""; - } - const { fontSize = "m", image, mediaContain } = content; - const { feedData = [], feed = {} } = slide; + const { feedData = [], feed = {} } = slide ?? {}; const { configuration = {} } = feed; const { entryDuration = 10, numberOfEntries = 5 } = configuration; + const fallbackRef = useRef(null); - const rootStyle = {}; - const feedLength = Math.min(numberOfEntries, feedData?.entries?.length ?? 0); - const imageUrl = getFirstMediaUrlFromField(slide.mediaData, image); + const feedEntries = feedData?.entries?.slice(0, numberOfEntries) ?? []; - // Set background image. - if (imageUrl) { - rootStyle.backgroundImage = `url("${imageUrl}")`; - } - - /** - * Capitalize the datestring, as it starts with the weekday. - * - * @param {string} s The string to capitalize. - * @returns {string} The capitalized string. - */ - const capitalize = (s) => { - return s.charAt(0).toUpperCase() + s.slice(1); - }; - - const entryDone = (index) => { - const nextIndex = index + 1; - - if (nextIndex >= feedLength) { - slideDone(slide); - } else { - setEntryIndex(nextIndex); - setCurrentEntry(feedData?.entries[nextIndex]); - timeoutRef.current = setTimeout(() => { - entryDone(nextIndex); - }, entryDuration * 1000); - } - }; + const { currentEntry, entryIndex } = useMultipleEntrySlideExecution({ + entries: feedEntries, + run, + slide, + slideDone, + entryDuration: entryDuration * 1000, + }); /** Sets localized formats (dayjs) */ useEffect(() => { dayjs.extend(localizedFormat); }, []); + // If no content, wait 1 second and continue to next slide. useEffect(() => { - if (run) { - entryDone(-1); + if (run && feedEntries.length === 0) { + fallbackRef.current = setTimeout(() => slideDone(slide), 1000); } + + return () => { + if (fallbackRef.current) { + clearTimeout(fallbackRef.current); + } + }; }, [run]); + const imageUrl = getFirstMediaUrlFromField(slide?.mediaData, image); + + const rootStyle = {}; + + // Set background image. + if (imageUrl) { + rootStyle.backgroundImage = `url("${imageUrl}")`; + } + + if (!slide?.feed) { + return null; + } + return ( <> - {currentEntry && ( - <> - {currentEntry.lastModified && ( - - {capitalize( - dayjs(currentEntry.lastModified) - .locale(localeDa) - .format("LLLL"), - )} - + {currentEntry?.lastModified && ( + + {capitalize( + dayjs(currentEntry.lastModified) + .locale(localeDa) + .format("LLLL"), )} - + )} {slide?.feedData?.title} - {slide?.feed.configuration.showFeedProgress && ( + {slide?.feed?.configuration?.showFeedProgress && ( - {feedLength > 0 && ( + {feedEntries.length > 0 && ( - {entryIndex + 1} / {feedLength} + {entryIndex + 1} / {feedEntries.length} )} diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx index 2e6b10232..541f90910 100644 --- a/assets/shared/templates/slideshow.jsx +++ b/assets/shared/templates/slideshow.jsx @@ -3,6 +3,7 @@ import { getAllMediaUrlsFromField, ThemeStyles, } from "../slide-utils/slide-util.jsx"; +import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js"; import "../slide-utils/global-styles.css"; import "./slideshow/slideshow.scss"; import templateConfig from "./slideshow.json"; @@ -52,15 +53,20 @@ function Slideshow({ slide, content, run, slideDone, executionId }) { const imageDurationInMilliseconds = imageDuration * 1000; - const [index, setIndex] = useState(0); const [fade, setFade] = useState(false); const [animationIndex, setAnimationIndex] = useState(0); + // Two stable keyframe slots keyed by index % 2 (matching animation names). + // A slot only updates when its animation genuinely needs new keyframes, + // preventing unnecessary + )} ); diff --git a/assets/shared/templates/table.jsx b/assets/shared/templates/table.jsx index 6931da612..a6481c67d 100644 --- a/assets/shared/templates/table.jsx +++ b/assets/shared/templates/table.jsx @@ -1,6 +1,5 @@ -import { useEffect } from "react"; import styled from "styled-components"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getFirstMediaUrlFromField, ThemeStyles, @@ -68,15 +67,7 @@ function Table({ slide, content, run, slideDone, executionId }) { rootStyle.backgroundImage = `url("${backgroundImageUrl}")`; } - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } else { - slideExecution.stop(); - } - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); let gridStyle; if (header) { diff --git a/assets/shared/templates/travel.jsx b/assets/shared/templates/travel.jsx index a11d6eda9..5eb68f939 100644 --- a/assets/shared/templates/travel.jsx +++ b/assets/shared/templates/travel.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import DOMPurify from "dompurify"; import parse from "html-react-parser"; import { IntlProvider, FormattedMessage } from "react-intl"; -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { getFirstMediaUrlFromField, ThemeStyles, @@ -109,17 +109,7 @@ function Travel({ iFrameClass = "iframe grow"; } - // Setup slide run function - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); // Create url useEffect(() => { diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx index ffb43879c..44984b4f6 100644 --- a/assets/shared/templates/video.jsx +++ b/assets/shared/templates/video.jsx @@ -41,43 +41,67 @@ function renderSlide(slide, run, slideDone) { function Video({ slide, content, run, slideDone, executionId }) { const videoUrls = getAllMediaUrlsFromField(slide.mediaData, content.video); const videoRef = useRef(); + const doneRef = useRef(false); const { sound, mediaContain } = content; - const onEnded = () => { - slideDone(slide); - }; - - const onError = () => { - slideDone(slide); + const finish = () => { + if (!doneRef.current) { + doneRef.current = true; + slideDone(slide); + } }; useEffect(() => { - if (run) { - videoRef?.current?.load(); - videoRef?.current?.addEventListener("ended", onEnded); - videoRef?.current?.addEventListener("error", onError); - videoRef.current.muted = true; - - if (sound) { - videoRef.current.muted = false; - } + if (!run) return; - const promise = videoRef.current.play(); + doneRef.current = false; + + if (videoUrls.length === 0) { + finish(); + return; + } + + const video = videoRef.current; + if (!video) { + finish(); + return; + } - if (promise !== undefined) { - promise - .then(() => {}) - .catch(() => { - if (videoRef?.current) { - videoRef.current.controls = true; - } - }); + let guardTimeout = null; + + const onLoadedMetadata = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + // Allow 10% extra time for buffering delays. + const guardMs = video.duration * 1.1 * 1000; + guardTimeout = setTimeout(finish, guardMs); } + }; + + video.addEventListener("ended", finish); + video.addEventListener("error", finish); + video.addEventListener("loadedmetadata", onLoadedMetadata); + + video.load(); + video.muted = !sound; + + const promise = video.play(); + + if (promise !== undefined) { + promise + .then(() => {}) + .catch(() => { + video.controls = true; + finish(); + }); } return () => { - videoRef?.current?.removeEventListener("ended", onEnded); - videoRef?.current?.removeEventListener("error", onError); + video.removeEventListener("ended", finish); + video.removeEventListener("error", finish); + video.removeEventListener("loadedmetadata", onLoadedMetadata); + if (guardTimeout !== null) { + clearTimeout(guardTimeout); + } }; }, [run]); diff --git a/assets/shared/templates/vimeo-player.jsx b/assets/shared/templates/vimeo-player.jsx index 9538d81da..d2f882552 100644 --- a/assets/shared/templates/vimeo-player.jsx +++ b/assets/shared/templates/vimeo-player.jsx @@ -1,6 +1,5 @@ -import { useEffect } from "react"; import Vimeo from "@u-wave/react-vimeo"; // eslint-disable-line import/no-unresolved -import BaseSlideExecution from "../slide-utils/base-slide-execution.js"; +import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js"; import { ThemeStyles } from "../slide-utils/slide-util.jsx"; import "../slide-utils/global-styles.css"; import "./vimeo-player/vimeo-player.scss"; @@ -40,17 +39,7 @@ function renderSlide(slide, run, slideDone) { function VimeoPlayer({ slide, content, run, slideDone, executionId }) { const { vimeoid, duration = 15000, mediaContain } = content; - /** Setup slide run function. */ - const slideExecution = new BaseSlideExecution(slide, slideDone); - useEffect(() => { - if (run) { - slideExecution.start(duration); - } - - return function cleanup() { - slideExecution.stop(); - }; - }, [run]); + useBaseSlideExecution({ slide, run, slideDone, duration }); return ( <> diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index 55691a478..f40776b4a 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -1795,7 +1795,7 @@ const slideFixtures = [ mediaData: { "/v1/media/00000000000000000000000001": { assets: { - uri: "/fixtures/template/mountain1.jpeg", + uri: "/fixtures/template/images/mountain1.jpeg", }, }, }, @@ -2023,6 +2023,59 @@ const slideFixtures = [ animation: "none", }, }, + { + id: "slideshow-3-random", + templateData: { + id: "01FP2SNSC9VXD10ZKXQR819NS9", + }, + themeFile: null, + theme: { + logo: { + assets: { + uri: "/fixtures/template/images/mountain1.jpeg", + }, + }, + }, + mediaData: { + "/v1/media/00000000000000000000000001": { + assets: { + uri: "/fixtures/template/images/mountain1.jpeg", + }, + }, + "/v1/media/00000000000000000000000002": { + assets: { + uri: "/fixtures/template/images/mountain2.jpeg", + }, + }, + "/v1/media/00000000000000000000000003": { + assets: { + uri: "/fixtures/template/images/mountain3.jpeg", + }, + }, + "/v1/media/00000000000000000000000004": { + assets: { + uri: "/fixtures/template/images/mountain4.jpeg", + }, + }, + }, + // Disable dark mode for slide. + darkModeEnabled: false, + content: { + imageDuration: 5, + images: [ + "/v1/media/00000000000000000000000001", + "/v1/media/00000000000000000000000002", + "/v1/media/00000000000000000000000003", + "/v1/media/00000000000000000000000004", + ], + showLogo: true, + logoSize: "l", + mediaContain: false, + logoPosition: "logo-position-top-right", + transition: "fade", + animation: "random", + }, + }, { id: "table-0", templateData: { diff --git a/assets/tests/template/template-slide-execution.spec.js b/assets/tests/template/template-slide-execution.spec.js new file mode 100644 index 000000000..718bd5655 --- /dev/null +++ b/assets/tests/template/template-slide-execution.spec.js @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; + +test.describe("useBaseSlideExecution hook", () => { + test("Should call slideDone after duration expires", async ({ page }) => { + // book-review-0 has duration: 5000 and uses useBaseSlideExecution. + const consoleDone = page.waitForEvent("console", { + predicate: (msg) => msg.text() === "slide done", + timeout: 10000, + }); + + await page.goto("/template/book-review-0"); + await expect(page.locator(".template-book-review")).toBeVisible(); + + const start = Date.now(); + await consoleDone; + const elapsed = Date.now() - start; + + // Duration is 5000ms. Allow a margin for browser/timer variance. + expect(elapsed).toBeGreaterThan(4000); + expect(elapsed).toBeLessThan(8000); + }); + + test("Should not call slideDone if slide is removed before duration", async ({ + page, + }) => { + // Navigate to a slide, then navigate away before duration (5000ms) expires. + let slideDoneCalled = false; + + page.on("console", (msg) => { + if (msg.text() === "slide done") { + slideDoneCalled = true; + } + }); + + await page.goto("/template/book-review-0"); + await expect(page.locator(".template-book-review")).toBeVisible(); + + // Navigate away after 1s, well before the 5s duration. + await page.waitForTimeout(1000); + await page.goto("/template"); + + // Wait a bit past when the timer would have fired. + await page.waitForTimeout(6000); + + expect(slideDoneCalled).toBe(false); + }); +});