Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/src/lib/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export function getDefaultGroup(): Omit<Group, 'id' | 'name'> {
oneAtATime: true,
autoPlay: false,
loop: false,
autoStep: false,
playoutMode: PlayoutMode.NORMAL,
parts: [],
playout: {
Expand Down
52 changes: 43 additions & 9 deletions apps/app/src/lib/playout/preparedGroupPlayData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/models/rundown/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 31 additions & 1 deletion apps/app/src/react/components/rundown/GroupView/GroupView.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -725,6 +738,23 @@ export const GroupView: React.FC<{
<MdPlaylistPlay size={22} />
</ToggleButton>

{group.playoutMode === PlayoutMode.SCHEDULE && (
<ToggleButton
title={
group.autoStep
? 'Auto Step enabled.\n\nEach scheduled start time will play the next part in sequence.\n\nClick to disable.'
: 'Enable Auto Step (cycle through parts at each scheduled start time).'
}
value="auto-step-schedule"
selected={group.autoStep ?? false}
size="small"
disabled={group.locked}
onChange={toggleAutoStep}
>
<AiFillStepForward size={22} />
</ToggleButton>
)}

<ToggleButton
title={
'Assign Button Area' +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,26 @@ export const SideBarEditGroup: React.FC<{
}}
/>
)}
<div className="setting">
<BooleanInput
label="Auto Step (cycle through parts)"
{...inputValue(modifiableGroups, (g) => g.autoStep, undefined)}
disabled={modifiableGroups.length === 0}
onChange={(value) => {
modifiableGroups.forEach((g) => {
ipcServer
.updateGroup({
rundownId,
groupId: g.id,
group: {
autoStep: value,
},
})
.catch(handleError)
})
}}
/>
</div>
</div>
)}
</>
Expand Down
6 changes: 3 additions & 3 deletions shared/packages/tsr-bridge/src/sideload/CasparCG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading