diff --git a/plugins/caspar/app/App.jsx b/plugins/caspar/app/App.jsx index 4a19ac60..ccc4216f 100644 --- a/plugins/caspar/app/App.jsx +++ b/plugins/caspar/app/App.jsx @@ -3,6 +3,7 @@ import React from 'react' import * as SharedContext from './sharedContext' import * as Settings from './views/Settings' +import { InspectorRange } from './views/InspectorRange' import { InspectorServer } from './views/InspectorServer' import { InspectorTemplate } from './views/InspectorTemplate' import { InspectorTransition } from './views/InspectorTransition' @@ -31,6 +32,8 @@ export default function App () { return case 'inspector/template': return + case 'inspector/range': + return case 'settings/servers': return case 'liveSwitch': diff --git a/plugins/caspar/app/components/LibraryListItem/index.jsx b/plugins/caspar/app/components/LibraryListItem/index.jsx index 417fedf9..881f7354 100644 --- a/plugins/caspar/app/components/LibraryListItem/index.jsx +++ b/plugins/caspar/app/components/LibraryListItem/index.jsx @@ -43,7 +43,9 @@ const ITEM_CONSTRUCTORS = [ target: item.name, ...(DEFAULT_VALUES[item.type] || {}) }, - duration: asset.calculateDurationMs(item) + duration: asset.calculateDurationMs(item), + medialength: Number.parseInt(item?.duration), + framerate: asset.frameRateFractionToDecimal(item?.framerate) } } } diff --git a/plugins/caspar/app/components/MediaSeek/index.jsx b/plugins/caspar/app/components/MediaSeek/index.jsx new file mode 100644 index 00000000..5feff57c --- /dev/null +++ b/plugins/caspar/app/components/MediaSeek/index.jsx @@ -0,0 +1,167 @@ +import React, { useRef, useEffect, useState } from 'react' +import { millisecondsToTime } from '../../utils/asset.cjs' +import "./style.css" + +const MediaHandle = ({ style, handlePointerDown, type, timestamp }) => { + return ( +
handlePointerDown(type)} + > + {timestamp} +
+ ) +} + +const MediaSelectionBar = ({ + handlePositions, + handlePointerDown, + maxValue, +}) => { + const [lastActiveHandle, setLastActiveHandle] = useState(null) + + const pointerDown = (type) => { + handlePointerDown(type) + setLastActiveHandle(type) + } + + const isFullRange = + handlePositions.in === 0 && handlePositions.out === maxValue + const leftHandlePos = (handlePositions.in / maxValue) * 100 + const rightHandlePos = (handlePositions.out / maxValue) * 100 + return ( + <> +
+
+
handlePointerDown("bar")} + style={{ + opacity: isFullRange ? 0 : 1, + + pointerEvents: isFullRange ? "none" : "auto", + }} + > + = +
+
+
+ +
+ + +
+ + ) +} + +export const MediaSeek = ({ inValue, outValue, maxValue, onChange }) => { + const trackRef = useRef(null) + const [dragging, setDragging] = useState(null) + const [handlePositions, setHandlePositions] = useState({ + in: inValue, + out: outValue, + }) + + useEffect(() => { + setHandlePositions({ in: inValue, out: outValue }) + }, [inValue, outValue]) + + const handlePointerMove = (e) => { + const rect = trackRef.current.getBoundingClientRect() + const percent = (e.clientX - rect.left) / rect.width + const clampedPercent = Math.max(0, Math.min(1, percent)) + const value = clampedPercent * maxValue + + if (dragging === "in") { + setHandlePositions((prev) => ({ + ...prev, + in: Math.min(value, prev.out), + })) + } else if (dragging === "out") { + setHandlePositions((prev) => ({ + ...prev, + out: Math.max(value, prev.in), + })) + } else { // dragging the whole bar + const range = handlePositions.out - handlePositions.in + let newInPoint = value - range / 2 + let newOutPoint = value + range / 2 + + // Don't allow the bar to be dragged out of bounds + if (newInPoint <= 0) { + newInPoint = 0 + newOutPoint = range + } else if (newOutPoint >= maxValue) { + newOutPoint = maxValue + newInPoint = maxValue - range + } + + setHandlePositions(() => ({ + in: newInPoint, + out: newOutPoint, + })) + } + } + + const handlePointerDown = (type) => { + setDragging(type) + } + + const handlePointerUp = () => { + // Only trigger onChange if values have changed + if (handlePositions.in !== inValue || handlePositions.out !== outValue) { + onChange(handlePositions.in, handlePositions.out) + } + setDragging(null) + } + + useEffect(() => { + if (dragging) { + window.addEventListener("pointermove", handlePointerMove) + window.addEventListener("pointerup", handlePointerUp) + } + + return () => { + window.removeEventListener("pointermove", handlePointerMove) + window.removeEventListener("pointerup", handlePointerUp) + } + }, [dragging, handlePointerMove, handlePointerUp]) + + return ( +
+
+ +
+
+ ) +} diff --git a/plugins/caspar/app/components/MediaSeek/style.css b/plugins/caspar/app/components/MediaSeek/style.css new file mode 100644 index 00000000..8f34d988 --- /dev/null +++ b/plugins/caspar/app/components/MediaSeek/style.css @@ -0,0 +1,89 @@ +.MediaSeekBar { + width: 100%; + margin: 0; + user-select: none; + font-family: sans-serif; + border-radius: 0.6rem; +} + +.MediaSeekBar-track { + position: relative; + height: 34px; + background-color: var(--base-color--shade2); + border-radius: inherit; + cursor: initial; + box-shadow: inset 0 0 0 1px var(--base-color--shade2); + overflow: visible; +} + +.MediaSelectionBar { + position: relative; + height: inherit; + border-radius: inherit; + overflow: hidden; + cursor: initial; +} + +.MediaSelectionBar-highlighted { + position: absolute; + top: 0; + bottom: 0; + background-color: var(--base-color--shade4); + opacity: 0.5; + border-radius: 0.25rem; +} + +.MediaSelectionBar-highlightedBar { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + font-size: 100%; + color: var(--base-color--shade3); + user-select: none; + cursor: grab; + padding: 0.1rem 0.5rem; +} + +.MediaSelectionBar-highlightedBar:active { + cursor: grabbing; +} + +.MediaHandle { + position: absolute; + top: 15%; + width: 10px; + height: 70%; + background-color: var(--base-color); + border-radius: 0.25rem; + cursor: ew-resize; + transform: translateX(-50%); +} + +.MediaHandle--Value { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 4px; + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; + z-index: 1; + font-family: var(--base-fontFamily--primary); + color: var(--base-color); +} + +.MediaHandle::after { + content: ""; + position: absolute; + top: 15%; + height: 70%; + left: 50%; + transform: translateX(-50%); + width: 20%; + border-radius: inherit; + background-color: invert(var(--base-color)); +} diff --git a/plugins/caspar/app/utils/asset.cjs b/plugins/caspar/app/utils/asset.cjs index c6e180b8..e3ac3e06 100644 --- a/plugins/caspar/app/utils/asset.cjs +++ b/plugins/caspar/app/utils/asset.cjs @@ -62,16 +62,15 @@ function calculateDurationMs (item) { if (!item) { return DEFAULT_DURATION_MS } - // If the item is a still image, return 0 + if (item.type === 'STILL') { return 0 } - // If the item has no duration, return the default duration + if (isNaN(Number(item?.duration))) { return DEFAULT_DURATION_MS } - // If the item has no framerate, return the default duration if (!item?.framerate) { return DEFAULT_DURATION_MS } @@ -114,3 +113,69 @@ function frameRateFractionToDecimal (fraction) { return dividend / divisor } +exports.frameRateFractionToDecimal = frameRateFractionToDecimal + +/** + * Build a string in hours:minutes:seconds format from milliseconds + * @example + * 1000 -> '00:01' + * 61000 -> '01:01' + * 3600000 -> '01:00:00' + * @param {number} ms + * @returns {string} + */ +function millisecondsToTime(ms) { + if (ms < 0) return "00:00" + if (!ms) return "00:00" + if (isNaN(ms)) return "00:00" + + const totalSeconds = Math.floor(ms / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + const pad = (n) => n.toString().padStart(2, "0") + + if (hours > 0) { + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}` + } else { + return `${pad(minutes)}:${pad(seconds)}` + } +} +exports.millisecondsToTime = millisecondsToTime + +/** + * Frames to milliseconds + * @param {number} frames + * @param {number} framerate + * @returns {number} + */ +function framesToMilliseconds(frames, framerate) { + if (frames < 0) return 0 + if (!frames) return 0 + if (isNaN(frames)) return 0 + if (framerate <= 0) return 0 + if (!framerate) return 0 + if (isNaN(framerate)) return 0 + + // Round to 1 decimal place + return Math.round((frames / framerate) * 1000 * 10) / 10 +} +exports.framesToMilliseconds = framesToMilliseconds + +/** + * Milliseconds to frames + * @param {number} ms + * @param {number} framerate + * @returns {number} + */ +function millisecondsToFrames(ms, framerate) { + if (ms < 0) return 0 + if (!ms) return 0 + if (isNaN(ms)) return 0 + if (framerate <= 0) return 0 + if (!framerate) return 0 + if (isNaN(framerate)) return 0 + + return Math.round((ms / 1000) * framerate) +} +exports.millisecondsToFrames = millisecondsToFrames diff --git a/plugins/caspar/app/utils/asset.unit.test.js b/plugins/caspar/app/utils/asset.unit.test.js index 80536f3e..d24cc04f 100644 --- a/plugins/caspar/app/utils/asset.unit.test.js +++ b/plugins/caspar/app/utils/asset.unit.test.js @@ -1,4 +1,4 @@ -const { calculateDurationMs } = require('./asset.cjs') +const { calculateDurationMs, millisecondsToTime, framesToMilliseconds, millisecondsToFrames} = require('./asset.cjs') test('calculateDurationMs should return 0 for 0 duration (string or number)', () => { // Test for both string and number '0' @@ -53,3 +53,80 @@ test('calculateDurationMs should return 0 for STILL image', () => { } expect(calculateDurationMs(item)).toEqual(0) }) + +test('calculateDurationMs should return default time for non-numeric duration', () => { + const item = { + framerate: '1/25', + duration: 'not-a-number' + } + expect(calculateDurationMs(item)).toEqual(5000) +}) + +test('returns "00:00" for 0 or negative input', () => { + expect(millisecondsToTime(0)).toEqual('00:00') + expect(millisecondsToTime(-100)).toEqual('00:00') + expect(millisecondsToTime(null)).toEqual('00:00') + expect(millisecondsToTime(undefined)).toEqual('00:00') +}) + +test('converts seconds correctly', () => { + expect(millisecondsToTime(1000)).toEqual('00:01') + expect(millisecondsToTime(61000)).toEqual('01:01') +}) + +test('converts hours correctly', () => { + expect(millisecondsToTime(3600000)).toEqual('01:00:00') + expect(millisecondsToTime(3661000)).toEqual('01:01:01') +}) + +test('handles arbitrary times', () => { + expect(millisecondsToTime(7325000)).toEqual('02:02:05') +}) + +test('handles non-numeric input gracefully', () => { + expect(millisecondsToTime('not-a-number')).toEqual('00:00') + expect(millisecondsToTime({})).toEqual('00:00') + expect(millisecondsToTime([])).toEqual('00:00') +}) + +test('handles large durations', () => { + expect(millisecondsToTime(100 * 3600000)).toEqual('100:00:00') +}) + +test('framesToMilliseconds converts frames to milliseconds correctly', () => { + expect(framesToMilliseconds(25, 25)).toEqual(1000) + expect(framesToMilliseconds(50, 25)).toEqual(2000) + expect(framesToMilliseconds(30, 29.97)).toEqual(1001.0) +}) + +test('framesToMilliseconds returns 0 for non-numeric frames', () => { + expect(framesToMilliseconds('not-a-number', 25)).toEqual(0) +}) + +test('framesToMilliseconds returns 0 for invalid framerate', () => { + expect(framesToMilliseconds(25, 'invalid')).toEqual(0) +}) + +test('framesToMilliseconds returns 0 for zero or negative framerate', () => { + expect(framesToMilliseconds(25, 0)).toEqual(0) + expect(framesToMilliseconds(25, -25)).toEqual(0) +}) + +test('millisecondsToFrames converts milliseconds to frames correctly', () => { + expect(millisecondsToFrames(1000, 25)).toEqual(25) + expect(millisecondsToFrames(2000, 25)).toEqual(50) + expect(millisecondsToFrames(1001, 29.97)).toBeCloseTo(30) +}) + +test('millisecondsToFrames returns 0 for non-numeric milliseconds', () => { + expect(millisecondsToFrames('not-a-number', 25)).toEqual(0) +}) + +test('millisecondsToFrames returns 0 for invalid framerate', () => { + expect(millisecondsToFrames(1000, 'invalid')).toEqual(0) +}) + +test('millisecondsToFrames returns 0 for zero or negative framerate', () => { + expect(millisecondsToFrames(1000, 0)).toEqual(0) + expect(millisecondsToFrames(1000, -25)).toEqual(0) +}) diff --git a/plugins/caspar/app/views/InspectorRange.jsx b/plugins/caspar/app/views/InspectorRange.jsx new file mode 100644 index 00000000..94fdffea --- /dev/null +++ b/plugins/caspar/app/views/InspectorRange.jsx @@ -0,0 +1,79 @@ +import React from 'react' +import bridge from 'bridge' +import { SharedContext } from '../sharedContext' +import { MediaSeek } from '../components/MediaSeek' +import { framesToMilliseconds, millisecondsToFrames} from '../utils/asset.cjs' + +export const InspectorRange = () => { + const [state] = React.useContext(SharedContext) + const [selection, setSelection] = React.useState([]) + + // Fetch selection when state changes + React.useEffect(() => { + async function updateSelection() { + const sel = await bridge.client.selection.getSelection() + setSelection(sel) + } + updateSelection() + }, [state]) + + + const items = selection.map(id => state?.items?.[id]) + const item = items[0] + if (!item) return null + + // Only allow seek for one item at a time + if (items.length > 1) { + return ( +
+

Multiple items selected

+
+ ) + } + + // Get parameters from the first selected item + const framerate = item?.data?.framerate || 25 + const medialength = item?.data?.medialength || millisecondsToFrames(item?.data?.duration, framerate) + // Don't show seek bar if length is 0 + if (medialength === 0) return null + + const seek = Number.parseInt(item?.data?.caspar?.seek) || 0 + const length = item?.data?.caspar?.length || (medialength - seek) + + // Convert frames to milliseconds + const inValue = framesToMilliseconds(seek, framerate) + const outValue = framesToMilliseconds(seek + length, framerate) + const maxValue = framesToMilliseconds(medialength, framerate) + + function handleChange (newIn, newOut) { + const id = selection[0] + const item = state?.items?.[id] + const framerate = item?.data?.framerate || 25 + + // newIn and newOut are in milliseconds + const seekFrames = millisecondsToFrames(newIn, framerate) // frames + const lengthFrames = millisecondsToFrames(newOut - newIn, framerate) // frames + const duration = Math.round(newOut - newIn) // milliseconds + + bridge.items.applyItem(id, { + data: { + caspar: { + seek: seekFrames, + length: lengthFrames + }, + duration: duration + } + }) + } + + return ( +
+ +
+ ) +} diff --git a/plugins/caspar/lib/handlers.js b/plugins/caspar/lib/handlers.js index 3b2955b0..f9c569dd 100644 --- a/plugins/caspar/lib/handlers.js +++ b/plugins/caspar/lib/handlers.js @@ -22,13 +22,13 @@ const PLAY_HANDLERS = { return commands.sendString(serverId, item?.data?.caspar?.amcp) }, 'bridge.caspar.media': async (serverId, item) => { - return commands.sendCommand(serverId, 'play', item?.data?.caspar?.target, item?.data?.caspar?.loop, 0, undefined, undefined, undefined, item?.data?.caspar) + return commands.sendCommand(serverId, 'play', item?.data?.caspar?.target, item?.data?.caspar?.loop, item?.data?.caspar?.seek, item?.data?.caspar?.length, undefined, undefined, item?.data?.caspar) }, 'bridge.caspar.image-scroller': async (serverId, item) => { return commands.sendCommand(serverId, 'playImageScroller', item?.data?.caspar?.target, item?.data?.caspar) }, 'bridge.caspar.load': async (serverId, item) => { - return commands.sendCommand(serverId, 'loadbg', item?.data?.caspar?.target, item?.data?.caspar?.loop, 0, undefined, undefined, item?.data?.caspar?.auto, item?.data?.caspar) + return commands.sendCommand(serverId, 'loadbg', item?.data?.caspar?.target, item?.data?.caspar?.loop, item?.data?.caspar?.seek, item?.data?.caspar?.length, undefined, item?.data?.caspar?.auto, item?.data?.caspar) }, 'bridge.caspar.template': (serverId, item) => { return commands.sendCommand(serverId, 'cgAdd', item?.data?.caspar?.target, getCleanTemplateDataString(item), true, item?.data?.caspar) diff --git a/plugins/caspar/lib/types.js b/plugins/caspar/lib/types.js index d9dc0871..9c4b348a 100644 --- a/plugins/caspar/lib/types.js +++ b/plugins/caspar/lib/types.js @@ -189,6 +189,21 @@ function init (htmlPath) { type: 'string', 'ui.group': 'Transition', 'ui.uri': `${htmlPath}?path=inspector/transition` + }, + 'caspar.seek': { + name: 'Seek', + type: 'string', + default: '0', + allowsVariables: true, + 'ui.group': 'Timing', + 'ui.unit': 'frames' + }, + 'caspar.range': { + name: '', + type: 'string', + allowsVariables: false, + 'ui.group': 'Timing', + 'ui.uri': `${htmlPath}?path=inspector/range` } } })