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..120090a6 100644 --- a/apps/app/src/lib/playout/preparedGroupPlayData.ts +++ b/apps/app/src/lib/playout/preparedGroupPlayData.ts @@ -66,21 +66,55 @@ 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) { + // 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 && occurrenceIndex >= playableParts.length) { + break + } + + // 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, + partId: partToPlay.id, + fromSchedule: true, + }) + + // Increment occurrence index only for times that pass the filter + occurrenceIndex++ + } + } + } else { + // Original behavior: always play the first part + const firstPlayablePart = playableParts[0] + if (firstPlayablePart) { + 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) + }) + }} + /> +
)} diff --git a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts index 4bb51879..a107ab89 100644 --- a/shared/packages/tsr-bridge/src/sideload/CasparCG.ts +++ b/shared/packages/tsr-bridge/src/sideload/CasparCG.ts @@ -156,16 +156,16 @@ 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 }) + const thumbnailQuery = await this.ccg.thumbnailRetrieve({ filename: `"${resource.name}"` }) if (thumbnailQuery.error) throw thumbnailQuery.error const thumbnail = await thumbnailQuery.request - if (this._isSuccessful(thumbnail)) { + 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) + resource.thumbnail = thumbnailData ? this._toPngDataUri(thumbnailData) : undefined TMP_THUMBNAIL_LIMIT-- } // else: probably CasparCG's media-scanner isn't running } catch (error) {