From eafb8fc5fe2188bad79f77735decce1db54b526d Mon Sep 17 00:00:00 2001 From: softwaredevzestgeek Date: Tue, 30 Dec 2025 13:06:32 +0530 Subject: [PATCH 1/9] feat: add Auto-Step feature for scheduled groups - Add autoStep property to Group model for cycling through parts at scheduled times - Implement Auto Step logic in scheduler to cycle through parts sequentially - Add Auto Step UI control in Group Settings sidebar (visible when in Schedule mode) - Add Auto Step toggle button in Group View header (visible when in Schedule mode) - Fix loop logic to prevent playing extra part when loop is disabled This feature allows multi-part groups to automatically cycle through parts at each scheduled start time, enabling playlist-like behavior for scheduled content. --- apps/app/src/lib/defaults.ts | 1 + .../src/lib/playout/preparedGroupPlayData.ts | 43 +++++++++++++++---- apps/app/src/models/rundown/Group.ts | 2 + .../rundown/GroupView/GroupView.tsx | 32 +++++++++++++- .../sidebar/editGroup/SideBarEditGroup.tsx | 20 +++++++++ 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/app/src/lib/defaults.ts b/apps/app/src/lib/defaults.ts index f926c367..2d6ca5b5 100644 --- a/apps/app/src/lib/defaults.ts +++ b/apps/app/src/lib/defaults.ts @@ -246,6 +246,7 @@ export function getDefaultGroup(): Omit { oneAtATime: true, autoPlay: false, loop: false, + autoStep: false, playoutMode: PlayoutMode.NORMAL, parts: [], playout: { diff --git a/apps/app/src/lib/playout/preparedGroupPlayData.ts b/apps/app/src/lib/playout/preparedGroupPlayData.ts index 970d6657..33028d53 100644 --- a/apps/app/src/lib/playout/preparedGroupPlayData.ts +++ b/apps/app/src/lib/playout/preparedGroupPlayData.ts @@ -66,21 +66,46 @@ export function prepareGroupPlayData(group: Group, now?: number): GroupPreparedP if (userAction) actions.push(userAction) if (group.playoutMode === PlayoutMode.SCHEDULE) { - const firstPlayablePart = getPlayablePartsAfter(group.parts, null)[0] - if (group.schedule.startTime && group.schedule.activate && firstPlayablePart) { + const playableParts = getPlayablePartsAfter(group.parts, null) + if (group.schedule.startTime && group.schedule.activate && playableParts.length > 0) { const repeatResult = repeatTime(group.schedule.startTime, group.schedule.repeating, { now: now, end: now + prepareValidDuration, maxCount: prepareValidMaxCount, }) - for (const startTime of repeatResult.startTimes) { - if (startTime >= (lastStopTime ?? 0)) { - actions.push({ - time: startTime, - partId: firstPlayablePart.id, - fromSchedule: true, - }) + // Auto Step: cycle through parts at each scheduled start time + if (group.autoStep) { + for (let i = 0; i < repeatResult.startTimes.length; i++) { + const startTime = repeatResult.startTimes[i] + if (startTime >= (lastStopTime ?? 0)) { + // If loop is disabled and we've cycled through all parts, stop scheduling + if (!group.loop && i >= playableParts.length) { + break + } + + // Calculate which part to play based on sequence number + const partIndex = i % playableParts.length + const partToPlay = playableParts[partIndex] + + actions.push({ + time: startTime, + partId: partToPlay.id, + fromSchedule: true, + }) + } + } + } else { + // Original behavior: always play the first part + const firstPlayablePart = playableParts[0] + for (const startTime of repeatResult.startTimes) { + if (startTime >= (lastStopTime ?? 0)) { + actions.push({ + time: startTime, + partId: firstPlayablePart.id, + fromSchedule: true, + }) + } } } validUntil = repeatResult.validUntil diff --git a/apps/app/src/models/rundown/Group.ts b/apps/app/src/models/rundown/Group.ts index 6fb6c6b9..79ece645 100644 --- a/apps/app/src/models/rundown/Group.ts +++ b/apps/app/src/models/rundown/Group.ts @@ -12,6 +12,8 @@ export interface GroupBase { oneAtATime: boolean autoPlay: boolean loop: boolean + /** When enabled in SCHEDULE mode, cycles through parts at each scheduled start time (one part per start time) */ + autoStep?: boolean disabled?: boolean locked?: boolean diff --git a/apps/app/src/react/components/rundown/GroupView/GroupView.tsx b/apps/app/src/react/components/rundown/GroupView/GroupView.tsx index 613e599e..6ec1a347 100644 --- a/apps/app/src/react/components/rundown/GroupView/GroupView.tsx +++ b/apps/app/src/react/components/rundown/GroupView/GroupView.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState, useContext, useCallback } from 'react' import { Sorensen } from '@sofie-automation/sorensen' import { TrashBtn } from '../../inputs/TrashBtn.js' -import { GroupBase, GroupGUI } from '../../../../models/rundown/Group.js' +import { GroupBase, GroupGUI, PlayoutMode } from '../../../../models/rundown/Group.js' import { PartView } from './PartView.js' import { GroupPreparedPlayData, SectionEndAction } from '../../../../models/GUI/PreparedPlayhead.js' import { IPCServerContext } from '../../../contexts/IPCServer.js' @@ -506,6 +506,19 @@ export const GroupView: React.FC<{ .catch(handleError) }, [group.autoPlay, group.id, handleError, ipcServer, rundownId]) + // Auto-step button (for scheduled groups): + const toggleAutoStep = useCallback(() => { + ipcServer + .updateGroup({ + rundownId, + groupId: group.id, + group: { + autoStep: !group.autoStep, + }, + }) + .catch(handleError) + }, [group.autoStep, group.id, handleError, ipcServer, rundownId]) + const assignedAreas = computed(() => allAssignedAreas.filter((assignedArea) => assignedArea.assignedToGroupId === group.id) ) @@ -725,6 +738,23 @@ export const GroupView: React.FC<{ + {group.playoutMode === PlayoutMode.SCHEDULE && ( + + + + )} + )} +
+ g.autoStep, undefined)} + disabled={modifiableGroups.length === 0} + onChange={(value) => { + modifiableGroups.forEach((g) => { + ipcServer + .updateGroup({ + rundownId, + groupId: g.id, + group: { + autoStep: value, + }, + }) + .catch(handleError) + }) + }} + /> +
)} From 3c7885fca9cc05456d34409914042e8caa2ccbca Mon Sep 17 00:00:00 2001 From: softwaredevzestgeek Date: Wed, 31 Dec 2025 13:02:57 +0530 Subject: [PATCH 2/9] fix: Auto Step uses occurrence index instead of array index for filtered start times - Fix bug where Auto Step incorrectly selected parts when scheduled start times were filtered out - Track occurrence index separately from array index to ensure correct part cycling - Increment occurrence index only when start time passes the filter (startTime >= lastStopTime) - This ensures parts cycle correctly even when some scheduled times are filtered due to currently playing parts Fixes issue where parts would be selected incorrectly based on array position rather than actual occurrence number when start times are filtered. --- apps/app/src/lib/playout/preparedGroupPlayData.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/app/src/lib/playout/preparedGroupPlayData.ts b/apps/app/src/lib/playout/preparedGroupPlayData.ts index 33028d53..19fc767b 100644 --- a/apps/app/src/lib/playout/preparedGroupPlayData.ts +++ b/apps/app/src/lib/playout/preparedGroupPlayData.ts @@ -76,16 +76,19 @@ export function prepareGroupPlayData(group: Group, now?: number): GroupPreparedP // Auto Step: cycle through parts at each scheduled start time if (group.autoStep) { + // Track occurrence index separately from array index + // This ensures correct part selection even when some start times are filtered out + let occurrenceIndex = 0 for (let i = 0; i < repeatResult.startTimes.length; i++) { const startTime = repeatResult.startTimes[i] if (startTime >= (lastStopTime ?? 0)) { // If loop is disabled and we've cycled through all parts, stop scheduling - if (!group.loop && i >= playableParts.length) { + if (!group.loop && occurrenceIndex >= playableParts.length) { break } - // Calculate which part to play based on sequence number - const partIndex = i % playableParts.length + // Calculate which part to play based on occurrence number, not array index + const partIndex = occurrenceIndex % playableParts.length const partToPlay = playableParts[partIndex] actions.push({ @@ -93,6 +96,9 @@ export function prepareGroupPlayData(group: Group, now?: number): GroupPreparedP partId: partToPlay.id, fromSchedule: true, }) + + // Increment occurrence index only for times that pass the filter + occurrenceIndex++ } } } else { From b46ee0062993a737f764cc8e924c92f247f143a3 Mon Sep 17 00:00:00 2001 From: softwaredevzestgeek Date: Wed, 14 Jan 2026 11:04:48 +0530 Subject: [PATCH 3/9] fix: add null safety checks for Auto Step part selection - Add null check for partToPlay in Auto Step cycling logic - Add null check for firstPlayablePart in non-Auto Step path - Addresses potential Copilot concerns about type safety --- .../src/lib/playout/preparedGroupPlayData.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/app/src/lib/playout/preparedGroupPlayData.ts b/apps/app/src/lib/playout/preparedGroupPlayData.ts index 19fc767b..120090a6 100644 --- a/apps/app/src/lib/playout/preparedGroupPlayData.ts +++ b/apps/app/src/lib/playout/preparedGroupPlayData.ts @@ -90,6 +90,7 @@ export function prepareGroupPlayData(group: Group, now?: number): GroupPreparedP // Calculate which part to play based on occurrence number, not array index const partIndex = occurrenceIndex % playableParts.length const partToPlay = playableParts[partIndex] + if (!partToPlay) continue actions.push({ time: startTime, @@ -104,13 +105,15 @@ export function prepareGroupPlayData(group: Group, now?: number): GroupPreparedP } else { // Original behavior: always play the first part const firstPlayablePart = playableParts[0] - for (const startTime of repeatResult.startTimes) { - if (startTime >= (lastStopTime ?? 0)) { - actions.push({ - time: startTime, - partId: firstPlayablePart.id, - fromSchedule: true, - }) + if (firstPlayablePart) { + for (const startTime of repeatResult.startTimes) { + if (startTime >= (lastStopTime ?? 0)) { + actions.push({ + time: startTime, + partId: firstPlayablePart.id, + fromSchedule: true, + }) + } } } } From 402d90eb99664ec8b85ab76f8d99eed084646b88 Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Sat, 17 Jan 2026 01:47:51 +0000 Subject: [PATCH 4/9] fix: add quotes around thumbnail request paths in ACMP commands --- shared/packages/tsr-bridge/src/sideload/CasparCG.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 4bb51879..695f9805 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -156,9 +156,7 @@ export class CasparCGSideload implements SideLoadDevice { if ((resource.type === 'image' || resource.type === 'video') && TMP_THUMBNAIL_LIMIT > 0) { try { - const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: resource.name }) - if (thumbnailQuery.error) throw thumbnailQuery.error - + const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: `"${resource.name}"` }) const thumbnail = await thumbnailQuery.request if (this._isSuccessful(thumbnail)) { const thumbnailData = From e6b7fc67cd8aaa6bf228ae846cf7c9e615923eed Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Sat, 17 Jan 2026 01:49:57 +0000 Subject: [PATCH 5/9] fix: restore error check line that was accidentally removed --- shared/packages/tsr-bridge/src/sideload/CasparCG.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 695f9805..4cfb1010 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -157,6 +157,8 @@ export class CasparCGSideload implements SideLoadDevice { if ((resource.type === 'image' || resource.type === 'video') && TMP_THUMBNAIL_LIMIT > 0) { try { const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: `"${resource.name}"` }) + if (thumbnailQuery.error) throw thumbnailQuery.error + const thumbnail = await thumbnailQuery.request if (this._isSuccessful(thumbnail)) { const thumbnailData = From f9c04790ac8cf8ee9d4abd34112f5bd72c228348 Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Sat, 17 Jan 2026 01:55:45 +0000 Subject: [PATCH 6/9] fix: add null check for thumbnail and fix indentation --- shared/packages/tsr-bridge/src/sideload/CasparCG.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 4cfb1010..9c9d26bb 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -159,10 +159,8 @@ export class CasparCGSideload implements SideLoadDevice { const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: `"${resource.name}"` }) if (thumbnailQuery.error) throw thumbnailQuery.error - const thumbnail = await thumbnailQuery.request - if (this._isSuccessful(thumbnail)) { - const thumbnailData = - Array.isArray(thumbnail.data) && typeof thumbnail.data[0] === 'string' + const thumbnail = await thumbnailQuery.request + if (thumbnail && this._isSuccessful(thumbnail)) { ? thumbnail.data[0] : undefined resource.thumbnail = thumbnailData && this._toPngDataUri(thumbnailData) From f5fcb87017d421ecf8d281b78f986e8c5b15e637 Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Sat, 17 Jan 2026 01:58:47 +0000 Subject: [PATCH 7/9] fix: restore missing lines in thumbnail code --- shared/packages/tsr-bridge/src/sideload/CasparCG.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 9c9d26bb..9cef904a 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -161,8 +161,8 @@ export class CasparCGSideload implements SideLoadDevice { const thumbnail = await thumbnailQuery.request if (thumbnail && this._isSuccessful(thumbnail)) { - ? thumbnail.data[0] - : undefined + const thumbnailData = + Array.isArray(thumbnail.data) && typeof thumbnail.data[0] === 'string' resource.thumbnail = thumbnailData && this._toPngDataUri(thumbnailData) TMP_THUMBNAIL_LIMIT-- } // else: probably CasparCG's media-scanner isn't running From c3002fd22758d7ff431ccb1dd00805779797e85a Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Sat, 17 Jan 2026 02:00:50 +0000 Subject: [PATCH 8/9] fix: properly handle thumbnail data type and restore missing lines --- shared/packages/tsr-bridge/src/sideload/CasparCG.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 9cef904a..4a952ee3 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -163,10 +163,12 @@ export class CasparCGSideload implements SideLoadDevice { if (thumbnail && this._isSuccessful(thumbnail)) { const thumbnailData = Array.isArray(thumbnail.data) && typeof thumbnail.data[0] === 'string' - resource.thumbnail = thumbnailData && this._toPngDataUri(thumbnailData) - TMP_THUMBNAIL_LIMIT-- - } // else: probably CasparCG's media-scanner isn't running - } catch (error) { + ? thumbnail.data[0] + : undefined + resource.thumbnail = thumbnailData ? this._toPngDataUri(thumbnailData) : undefined + TMP_THUMBNAIL_LIMIT-- + } // else: probably CasparCG's media-scanner isn't running + } catch (error) { this.log.error(`Could not set thumbnail for media "${resource.name}".`, error) } } From 501169f7de1d774b6e94fd866954f116eda81ada Mon Sep 17 00:00:00 2001 From: zcybercomputing Date: Sat, 17 Jan 2026 02:10:19 +0000 Subject: [PATCH 9/9] fix: correct indentation in thumbnail code --- .../tsr-bridge/src/sideload/CasparCG.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 4a952ee3..a107ab89 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -156,19 +156,19 @@ export class CasparCGSideload implements SideLoadDevice { if ((resource.type === 'image' || resource.type === 'video') && TMP_THUMBNAIL_LIMIT > 0) { try { - const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: `"${resource.name}"` }) - if (thumbnailQuery.error) throw thumbnailQuery.error - - const thumbnail = await thumbnailQuery.request - if (thumbnail && this._isSuccessful(thumbnail)) { - const thumbnailData = - Array.isArray(thumbnail.data) && typeof thumbnail.data[0] === 'string' - ? thumbnail.data[0] - : undefined - resource.thumbnail = thumbnailData ? this._toPngDataUri(thumbnailData) : undefined - TMP_THUMBNAIL_LIMIT-- - } // else: probably CasparCG's media-scanner isn't running - } catch (error) { + const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: `"${resource.name}"` }) + if (thumbnailQuery.error) throw thumbnailQuery.error + + const thumbnail = await thumbnailQuery.request + if (thumbnail && this._isSuccessful(thumbnail)) { + const thumbnailData = + Array.isArray(thumbnail.data) && typeof thumbnail.data[0] === 'string' + ? thumbnail.data[0] + : undefined + resource.thumbnail = thumbnailData ? this._toPngDataUri(thumbnailData) : undefined + TMP_THUMBNAIL_LIMIT-- + } // else: probably CasparCG's media-scanner isn't running + } catch (error) { this.log.error(`Could not set thumbnail for media "${resource.name}".`, error) } }