- {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}
)}
From 97d0f444af2e4a1d7340a97f61d0c0438ed3a8d2 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Sun, 12 Apr 2026 10:25:17 +0200
Subject: [PATCH 07/16] 6871: Changed to use useMultipleEntrySlideExecution
---
assets/shared/templates/instagram-feed.jsx | 55 ++++++--------
assets/shared/templates/news-feed.jsx | 88 ++++++----------------
2 files changed, 49 insertions(+), 94 deletions(-)
diff --git a/assets/shared/templates/instagram-feed.jsx b/assets/shared/templates/instagram-feed.jsx
index 2c3c1497..2e8c121b 100644
--- a/assets/shared/templates/instagram-feed.jsx
+++ b/assets/shared/templates/instagram-feed.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import dayjs from "dayjs";
import localeDa from "dayjs/locale/da";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -8,6 +8,7 @@ import DOMPurify from "dompurify";
import Shape from "./instagram-feed/shape.svg";
import InstagramLogo from "./instagram-feed/instagram-logo.svg";
import { ThemeStyles } from "../slide-utils/slide-util.jsx";
+import useMultipleEntrySlideExecution from "../slide-utils/useMultipleEntrySlideExecution.js";
import "../slide-utils/global-styles.css";
import "./instagram-feed/instagram-feed.scss";
import templateConfig from "./instagram-feed.json";
@@ -47,11 +48,10 @@ function InstagramFeed({ slide, content, run, slideDone, executionId }) {
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
- const [currentPost, setCurrentPost] = useState(null);
-
// Animation
const [show, setShow] = useState(true);
const animationDuration = 1500;
+ const fallbackRef = useRef(null);
const { feedData = [] } = slide;
const { hashtagText, orientation, imageWidth = null, mediaContain } = content;
@@ -63,44 +63,39 @@ function InstagramFeed({ slide, content, run, slideDone, executionId }) {
const { maxEntries = 5 } = content;
const maxEntriesToShow = Number.isInteger(maxEntries) ? maxEntries : 5;
+ const feedEntries = feedData?.slice(0, maxEntriesToShow) ?? [];
+
+ const { currentEntry: currentPost } = useMultipleEntrySlideExecution({
+ entries: feedEntries,
+ run,
+ slide,
+ slideDone,
+ entryDuration: duration,
+ });
- /** Setup feed entry switch and animation, if there is more than one post. */
+ // Trigger fade-out animation before entry changes.
useEffect(() => {
- 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 0fb6fe19..338a14b5 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;
From c09d8ca8a1890e67b28570d3616cb5dda764aacb Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Sun, 12 Apr 2026 10:54:27 +0200
Subject: [PATCH 08/16] 6871: Transition to use hook
---
assets/shared/templates/poster.jsx | 93 +++++--------
assets/shared/templates/slideshow.jsx | 145 +++++++++------------
assets/template/fixtures/slide-fixtures.js | 53 ++++++++
3 files changed, 147 insertions(+), 144 deletions(-)
diff --git a/assets/shared/templates/poster.jsx b/assets/shared/templates/poster.jsx
index 46501f4e..564c9d02 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/slideshow.jsx b/assets/shared/templates/slideshow.jsx
index 2e6b1023..4ac88223 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,17 @@ 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);
+ const [animationKeyframesCurrent, setAnimationKeyframesCurrent] = useState("");
+ const [animationKeyframesNext, setAnimationKeyframesNext] = useState("");
+ const fallbackRef = useRef(null);
const fadeEnabled = transition === "fade";
const fadeDuration = 1000;
const fadeSafeMargin = 50;
- const animationName = "animationForImage";
+ const getAnimationName = (i) => `animationForImage-${executionId}-${i % 2}`;
const animationDuration =
imageDurationInMilliseconds + (fadeEnabled ? fadeDuration * 2 : 0);
@@ -78,8 +81,13 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
logoClasses.push(logoPosition);
}
- const timeoutRef = useRef(null);
- const fadeRef = useRef(null);
+ const { entryIndex: index } = useMultipleEntrySlideExecution({
+ entries: imageUrls,
+ run,
+ slide,
+ slideDone,
+ entryDuration: imageDurationInMilliseconds,
+ });
/**
* A random function to simplify the code where random is used
@@ -98,12 +106,12 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
* @param {string} transform The transform.
* @returns {string} The animation.
*/
- function createAnimation(grow, transform = "50% 50%") {
+ function createAnimation(name, grow, transform = "50% 50%") {
const transformOrigin = transform;
const startSize = grow ? 1 : 1.2;
const finishSize = grow ? 1.2 : 1;
- return `@keyframes ${animationName} {
+ return `@keyframes ${name} {
0% {
transform: scale(${startSize});
transform-origin: ${transformOrigin};
@@ -129,7 +137,7 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
* @param {string} animationType The animation type.
* @returns {string | null} The current animation.
*/
- function getCurrentAnimation(animationType) {
+ function getCurrentAnimation(name, animationType) {
const animationTypes = [
"zoom-in-middle",
"zoom-out-middle",
@@ -140,15 +148,16 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
const randomPercent = `${random(100) + 1}% ${random(100) + 1}%`;
switch (animationType) {
case "zoom-in-middle":
- return createAnimation(true);
+ return createAnimation(name, true);
case "zoom-out-middle":
- return createAnimation(false);
+ return createAnimation(name, false);
case "zoom-in-random":
- return createAnimation(true, randomPercent);
+ return createAnimation(name, true, randomPercent);
case "zoom-out-random":
- return createAnimation(false, randomPercent);
+ return createAnimation(name, false, randomPercent);
case "random":
return getCurrentAnimation(
+ name,
animationTypes[random(animationTypes.length)],
);
default:
@@ -157,104 +166,67 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
}
// Get image style for the given image url.
- const getImageStyle = (imageUrl, enableAnimation, localAnimationDuration) => {
+ const getImageStyle = (
+ imageUrl,
+ imageIndex,
+ enableAnimation,
+ localAnimationDuration,
+ ) => {
const imageStyle = {
backgroundImage: `url(${imageUrl})`,
};
if (enableAnimation) {
- imageStyle.animation = `${animationName} ${localAnimationDuration}ms`;
+ imageStyle.animation = `${getAnimationName(imageIndex)} ${localAnimationDuration}ms`;
}
return imageStyle;
};
+ // If there are no images in slide, wait for 2s before continuing to avoid crashes.
useEffect(() => {
- // Setup animation
- if (animation) {
- // Adds the animation to the stylesheet. because there is an element of random, we cannot have it in the .scss file.
- const styleSheet = document.styleSheets[0];
- const currentAnimation = getCurrentAnimation(animation);
- if (currentAnimation !== null) {
- styleSheet.insertRule(
- getCurrentAnimation(animation),
- styleSheet.cssRules.length,
- );
- }
+ if (run && imageUrls.length === 0) {
+ fallbackRef.current = setTimeout(() => slideDone(slide), 2000);
}
return () => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- if (fadeRef.current) {
- clearTimeout(fadeRef.current);
+ if (fallbackRef.current) {
+ clearTimeout(fallbackRef.current);
}
};
- }, []);
-
- // Setup image progress.
- useEffect(() => {
- if (run) {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- if (fadeRef.current) {
- clearTimeout(fadeRef.current);
- }
-
- if (imageUrls.length === 0) {
- // If there are no images in slide, wait for 2s before continuing to avoid crashes.
- setTimeout(() => {
- slideDone(slide);
- }, 2000);
- } else {
- setFade(false);
- setIndex(0);
- setAnimationIndex(0);
- }
- }
}, [run]);
+ // Regenerate animation keyframes and trigger fade for each image.
+ // Pre-start the scale animation on the next image during the fade so
+ // the zoom is already in progress when the image becomes visible.
useEffect(() => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
+ setAnimationIndex(index);
+ setFade(false);
+
+ if (animation) {
+ const keyframes =
+ getCurrentAnimation(getAnimationName(index), animation) ?? "";
+ setAnimationKeyframesCurrent(keyframes);
}
- timeoutRef.current = setTimeout(() => {
- let newIndex = index + 1;
+ if (!fadeEnabled) return;
- if (newIndex === imageUrls.length) {
- newIndex = 0;
- }
+ const fadeTimer = setTimeout(() => {
+ setFade(true);
- if (newIndex !== 0) {
- setAnimationIndex(newIndex);
- }
-
- if (fadeEnabled && newIndex !== 0) {
- // Fade to next image.
- setFade(true);
+ const nextIndex = index + 1;
+ if (nextIndex < imageUrls.length) {
+ setAnimationIndex(nextIndex);
- if (fadeRef.current) {
- clearTimeout(fadeRef.current);
+ if (animation) {
+ const nextKeyframes =
+ getCurrentAnimation(getAnimationName(nextIndex), animation) ?? "";
+ setAnimationKeyframesNext(nextKeyframes);
}
-
- fadeRef.current = setTimeout(() => {
- setFade(false);
-
- if (newIndex === 0) {
- slideDone(slide);
- } else {
- setIndex(newIndex);
- }
- }, fadeDuration - fadeSafeMargin);
- } else if (newIndex === 0) {
- slideDone(slide);
- } else {
- setIndex(newIndex);
}
- }, imageDurationInMilliseconds);
+ }, imageDurationInMilliseconds - fadeDuration + fadeSafeMargin);
+
+ return () => clearTimeout(fadeTimer);
}, [index]);
return (
@@ -301,6 +273,7 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
+ {(animationKeyframesCurrent || animationKeyframesNext) && (
+
+ )}
>
);
diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js
index 55691a47..9ec62833 100644
--- a/assets/template/fixtures/slide-fixtures.js
+++ b/assets/template/fixtures/slide-fixtures.js
@@ -2023,6 +2023,59 @@ const slideFixtures = [
animation: "none",
},
},
+ {
+ id: "slideshow-3",
+ 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: true,
+ logoPosition: "logo-position-top-right",
+ transition: "fade",
+ animation: "random",
+ },
+ },
{
id: "table-0",
templateData: {
From 771aeeed93079354314d2c556c8df2522f607738 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Sun, 12 Apr 2026 11:13:07 +0200
Subject: [PATCH 09/16] 6871: Fixed slideshow animation reset issues
---
assets/shared/templates/slideshow.jsx | 33 ++++++++++++++++++++-------
1 file changed, 25 insertions(+), 8 deletions(-)
diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx
index 4ac88223..73db1545 100644
--- a/assets/shared/templates/slideshow.jsx
+++ b/assets/shared/templates/slideshow.jsx
@@ -55,9 +55,12 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
const [fade, setFade] = useState(false);
const [animationIndex, setAnimationIndex] = useState(0);
- const [animationKeyframesCurrent, setAnimationKeyframesCurrent] = useState("");
- const [animationKeyframesNext, setAnimationKeyframesNext] = useState("");
+ // Two stable keyframe slots keyed by index % 2 (matching animation names).
+ // A slot only updates when its animation genuinely needs new keyframes,
+ // preventing unnecessary
)}
From 65c5a9135394e744f9ad453f2654ec80e120c4d8 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Sun, 12 Apr 2026 11:19:17 +0200
Subject: [PATCH 10/16] 6871: Fixed fixture
---
assets/template/fixtures/slide-fixtures.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js
index 9ec62833..728af238 100644
--- a/assets/template/fixtures/slide-fixtures.js
+++ b/assets/template/fixtures/slide-fixtures.js
@@ -2024,7 +2024,7 @@ const slideFixtures = [
},
},
{
- id: "slideshow-3",
+ id: "slideshow-3-random",
templateData: {
id: "01FP2SNSC9VXD10ZKXQR819NS9",
},
@@ -2070,7 +2070,7 @@ const slideFixtures = [
],
showLogo: true,
logoSize: "l",
- mediaContain: true,
+ mediaContain: false,
logoPosition: "logo-position-top-right",
transition: "fade",
animation: "random",
From d5986daf263dc9c7bf526fcd993472ff87241587 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 13:45:16 +0200
Subject: [PATCH 11/16] 7258: Fixed paths where slideDown might not be called
---
assets/shared/templates/rss.jsx | 16 +++++++++++++++-
assets/shared/templates/video.jsx | 6 ++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/assets/shared/templates/rss.jsx b/assets/shared/templates/rss.jsx
index 52bf89dd..52069feb 100644
--- a/assets/shared/templates/rss.jsx
+++ b/assets/shared/templates/rss.jsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import dayjs from "dayjs";
import localeDa from "dayjs/locale/da";
import localizedFormat from "dayjs/plugin/localizedFormat";
@@ -58,6 +58,7 @@ function RSS({ slide, content, run, slideDone, executionId }) {
const { feedData = [], feed = {} } = slide ?? {};
const { configuration = {} } = feed;
const { entryDuration = 10, numberOfEntries = 5 } = configuration;
+ const fallbackRef = useRef(null);
const feedEntries = feedData?.entries?.slice(0, numberOfEntries) ?? [];
@@ -74,6 +75,19 @@ function RSS({ slide, content, run, slideDone, executionId }) {
dayjs.extend(localizedFormat);
}, []);
+ // If no content, wait 1 second and continue to next slide.
+ useEffect(() => {
+ 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 = {};
diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx
index ffb43879..3553ea43 100644
--- a/assets/shared/templates/video.jsx
+++ b/assets/shared/templates/video.jsx
@@ -53,6 +53,11 @@ function Video({ slide, content, run, slideDone, executionId }) {
useEffect(() => {
if (run) {
+ if (videoUrls.length === 0) {
+ slideDone(slide);
+ return;
+ }
+
videoRef?.current?.load();
videoRef?.current?.addEventListener("ended", onEnded);
videoRef?.current?.addEventListener("error", onError);
@@ -71,6 +76,7 @@ function Video({ slide, content, run, slideDone, executionId }) {
if (videoRef?.current) {
videoRef.current.controls = true;
}
+ slideDone(slide);
});
}
}
From f53ab04f3e9fa8e7cd043e1a7f9cf50c1e3437f5 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 13:55:18 +0200
Subject: [PATCH 12/16] 7258: Fixed video paths where slideDone might not be
called
---
assets/shared/templates/video.jsx | 78 +++++++++++++++++++------------
1 file changed, 47 insertions(+), 31 deletions(-)
diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx
index 3553ea43..8b0693d1 100644
--- a/assets/shared/templates/video.jsx
+++ b/assets/shared/templates/video.jsx
@@ -41,49 +41,65 @@ 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) {
- if (videoUrls.length === 0) {
- slideDone(slide);
- return;
- }
+ if (!run) return;
- videoRef?.current?.load();
- videoRef?.current?.addEventListener("ended", onEnded);
- videoRef?.current?.addEventListener("error", onError);
- videoRef.current.muted = true;
+ doneRef.current = false;
- if (sound) {
- videoRef.current.muted = false;
- }
+ if (videoUrls.length === 0) {
+ finish();
+ return;
+ }
+
+ const video = videoRef.current;
+ if (!video) {
+ finish();
+ return;
+ }
- const promise = videoRef.current.play();
-
- if (promise !== undefined) {
- promise
- .then(() => {})
- .catch(() => {
- if (videoRef?.current) {
- videoRef.current.controls = true;
- }
- slideDone(slide);
- });
+ 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]);
From fa293af5cb87dd4ee7540c57842654bbadb97324 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 19:07:38 +0200
Subject: [PATCH 13/16] 7258: Fixed fixture
---
assets/template/fixtures/slide-fixtures.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js
index 728af238..f40776b4 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",
},
},
},
From 0658a2c47119b93d11c0a593efb2671b3ca3996c Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:42:28 +0200
Subject: [PATCH 14/16] 7258: Only apply fade where there are more entries to
transition to
---
CHANGELOG.md | 6 ++++++
assets/shared/templates/slideshow.jsx | 3 +--
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3a3b015..4d0b19a0 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/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx
index 73db1545..a88d3e15 100644
--- a/assets/shared/templates/slideshow.jsx
+++ b/assets/shared/templates/slideshow.jsx
@@ -228,10 +228,9 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
if (!fadeEnabled) return;
const fadeTimer = setTimeout(() => {
- setFade(true);
-
const nextIndex = index + 1;
if (nextIndex < imageUrls.length) {
+ setFade(true);
setAnimationIndex(nextIndex);
if (animation) {
From 12f425c47af6e1be151da635f871852b4c6a454d Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:46:25 +0200
Subject: [PATCH 15/16] 7258: Moved base-slide-execution into hook instead of
as a separate file
---
.../slide-utils/base-slide-execution.js | 63 -------------------
.../slide-utils/useBaseSlideExecution.js | 21 +++----
2 files changed, 10 insertions(+), 74 deletions(-)
delete mode 100644 assets/shared/slide-utils/base-slide-execution.js
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 d59fb1b4..00000000
--- a/assets/shared/slide-utils/base-slide-execution.js
+++ /dev/null
@@ -1,63 +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);
- }
-
- const safeDuration =
- Number.isFinite(duration) && duration > 0 ? duration : 15000;
-
- // Wait duration then call slideDone.
- this.slideTimeout = setTimeout(() => {
- if (typeof this.slideDone === "function") {
- this.slideDone(this.slide);
- }
- this.slideTimeout = null;
- }, safeDuration);
- }
-
- /**
- * Stops execution timeout.
- *
- * Does not call slideDone — this is intentional for cleanup-on-unmount
- * scenarios where the slide was cancelled, not completed.
- */
- 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
index 80ce0b8c..855604e0 100644
--- a/assets/shared/slide-utils/useBaseSlideExecution.js
+++ b/assets/shared/slide-utils/useBaseSlideExecution.js
@@ -1,5 +1,4 @@
import { useEffect, useRef } from "react";
-import BaseSlideExecution from "./base-slide-execution.js";
/**
* Hook to manage slide execution lifecycle.
@@ -23,18 +22,18 @@ function useBaseSlideExecution({ slide, run, slideDone, duration }) {
durationRef.current = duration;
useEffect(() => {
- const slideExecution = new BaseSlideExecution(
- slideRef.current,
- (s) => slideDoneRef.current(s),
- );
+ if (!run) return;
- if (run) {
- slideExecution.start(durationRef.current);
- }
+ const safeDuration =
+ Number.isFinite(durationRef.current) && durationRef.current > 0
+ ? durationRef.current
+ : 15000;
- return function cleanup() {
- slideExecution.stop();
- };
+ const timeoutId = setTimeout(() => {
+ slideDoneRef.current(slideRef.current);
+ }, safeDuration);
+
+ return () => clearTimeout(timeoutId);
}, [run]);
}
From 522d02673d1de677f78e96af8ecaeaf9124ca7f5 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:47:36 +0200
Subject: [PATCH 16/16] 7258: Applied coding standards
---
assets/shared/templates/image-text.jsx | 1 -
assets/shared/templates/slideshow.jsx | 32 +++++++++++++++-----------
assets/shared/templates/video.jsx | 10 ++++----
3 files changed, 24 insertions(+), 19 deletions(-)
diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx
index ecbcec16..3d9ec992 100644
--- a/assets/shared/templates/image-text.jsx
+++ b/assets/shared/templates/image-text.jsx
@@ -194,7 +194,6 @@ function ImageText({ slide, content, run, slideDone, executionId }) {
} else {
setCurrentImage(undefined);
}
-
}, [images]);
useBaseSlideExecution({ slide, run, slideDone, duration });
diff --git a/assets/shared/templates/slideshow.jsx b/assets/shared/templates/slideshow.jsx
index a88d3e15..541f9091 100644
--- a/assets/shared/templates/slideshow.jsx
+++ b/assets/shared/templates/slideshow.jsx
@@ -221,26 +221,30 @@ function Slideshow({ slide, content, run, slideDone, executionId }) {
preparedNextKeyframesRef.current = null;
const keyframes =
prepared ??
- (getCurrentAnimation(getAnimationName(index), animation) ?? "");
+ getCurrentAnimation(getAnimationName(index), animation) ??
+ "";
updateKeyframeSlot(index, keyframes);
}
if (!fadeEnabled) return;
- const fadeTimer = setTimeout(() => {
- const nextIndex = index + 1;
- if (nextIndex < imageUrls.length) {
- setFade(true);
- setAnimationIndex(nextIndex);
-
- if (animation) {
- const nextKeyframes =
- getCurrentAnimation(getAnimationName(nextIndex), animation) ?? "";
- preparedNextKeyframesRef.current = nextKeyframes;
- updateKeyframeSlot(nextIndex, nextKeyframes);
+ const fadeTimer = setTimeout(
+ () => {
+ const nextIndex = index + 1;
+ if (nextIndex < imageUrls.length) {
+ setFade(true);
+ setAnimationIndex(nextIndex);
+
+ if (animation) {
+ const nextKeyframes =
+ getCurrentAnimation(getAnimationName(nextIndex), animation) ?? "";
+ preparedNextKeyframesRef.current = nextKeyframes;
+ updateKeyframeSlot(nextIndex, nextKeyframes);
+ }
}
- }
- }, imageDurationInMilliseconds - fadeDuration + fadeSafeMargin);
+ },
+ imageDurationInMilliseconds - fadeDuration + fadeSafeMargin,
+ );
return () => clearTimeout(fadeTimer);
}, [index]);
diff --git a/assets/shared/templates/video.jsx b/assets/shared/templates/video.jsx
index 8b0693d1..44984b4f 100644
--- a/assets/shared/templates/video.jsx
+++ b/assets/shared/templates/video.jsx
@@ -87,10 +87,12 @@ function Video({ slide, content, run, slideDone, executionId }) {
const promise = video.play();
if (promise !== undefined) {
- promise.then(() => {}).catch(() => {
- video.controls = true;
- finish();
- });
+ promise
+ .then(() => {})
+ .catch(() => {
+ video.controls = true;
+ finish();
+ });
}
return () => {