diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 37a9ddd0b6..f4841e06df 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -16,6 +16,7 @@ import withPreset from './features/PresetWrapper'; import withData from './features/viewers/ViewWrapper'; import { ONTIME_VERSION } from './ONTIME_VERSION'; import { sentryDsn, sentryRecommendedIgnore } from './sentry.config'; +import { Playback, TimerPhase, TimerType } from 'ontime-types'; const Editor = React.lazy(() => import('./features/editors/ProtectedEditor')); const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet')); @@ -23,6 +24,7 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport')); const TimerView = React.lazy(() => import('./features/viewers/timer/Timer')); const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer')); +const PopOutTimer = React.lazy(() => import('./features/viewers/pop-out-clock/PopOutTimer')); const ClockView = React.lazy(() => import('./features/viewers/clock/Clock')); const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown')); @@ -73,6 +75,30 @@ export default function AppRouter() { return ( + } /> } /> diff --git a/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.options.ts b/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.options.ts new file mode 100644 index 0000000000..224bf33646 --- /dev/null +++ b/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.options.ts @@ -0,0 +1,91 @@ +import { hideTimerSeconds } from '../../../common/components/view-params-editor/constants'; +import { ViewOption } from '../../../common/components/view-params-editor/types'; + +export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [ + { section: 'Timer Options' }, + hideTimerSeconds, + { section: 'Element visibility' }, + { + id: 'hideovertime', + title: 'Hide Overtime', + description: 'Whether to suppress overtime styles (red borders and red text)', + type: 'boolean', + defaultValue: false, + }, + { + id: 'hideendmessage', + title: 'Hide End Message', + description: 'Whether to hide end message and continue showing the clock if timer is in overtime', + type: 'boolean', + defaultValue: false, + }, + { section: 'View style override' }, + { + id: 'key', + title: 'Key Colour', + description: 'Background colour in hexadecimal', + prefix: '#', + type: 'string', + placeholder: '00000000 (default)', + }, + { + id: 'text', + title: 'Text Colour', + description: 'Text colour in hexadecimal', + prefix: '#', + type: 'string', + placeholder: 'fffff (default)', + }, + { + id: 'textbg', + title: 'Text Background', + description: 'Colour of text background in hexadecimal', + prefix: '#', + type: 'string', + placeholder: '00000000 (default)', + }, + { + id: 'font', + title: 'Font', + description: 'Font family, will use the fonts available in the system', + type: 'string', + placeholder: 'Arial Black (default)', + }, + { + id: 'size', + title: 'Text Size', + description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)', + type: 'number', + placeholder: '1 (default)', + }, + { + id: 'alignx', + title: 'Align Horizontal', + description: 'Moves the horizontally in page to start = left | center | end = right', + type: 'option', + values: { start: 'Start', center: 'Center', end: 'End' }, + defaultValue: 'center', + }, + { + id: 'offsetx', + title: 'Offset Horizontal', + description: 'Offsets the timer horizontal position by a given amount in pixels', + type: 'number', + placeholder: '0 (default)', + }, + { + id: 'aligny', + title: 'Align Vertical', + description: 'Moves the vertically in page to start = left | center | end = right', + type: 'option', + values: { start: 'Start', center: 'Center', end: 'End' }, + defaultValue: 'center', + }, + { + id: 'offsety', + title: 'Offset Vertical', + description: 'Offsets the timer vertical position by a given amount in pixels', + type: 'number', + placeholder: '0 (default)', + }, +]; diff --git a/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.scss b/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.scss new file mode 100644 index 0000000000..7acb934db5 --- /dev/null +++ b/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.scss @@ -0,0 +1,53 @@ +@use '../../../theme/viewerDefs' as *; + +.minimal-timer { + margin: 0; + box-sizing: border-box; /* reset */ + overflow: hidden; + width: 100%; /* restrict the page width to viewport */ + height: 100vh; + transition: opacity 0.5s ease-in-out; + + background: var(--background-color-override, $viewer-background-color); + color: var(--color-override, $viewer-color); + display: grid; + place-content: center; + + &--finished { + outline: clamp(4px, 1vw, 16px) solid $timer-finished-color; + outline-offset: calc(clamp(4px, 1vw, 16px) * -1); + transition: $viewer-transition-time; + } + + .timer { + opacity: 1; + font-family: var(--font-family-bold-override, $timer-bold-font-family) ; + font-size: 20vw; + position: relative; + color: var(--timer-color-override, var(--phase-color)); + transition: $viewer-transition-time; + transition-property: opacity; + background-color: transparent; + letter-spacing: 0.05em; + + &--paused { + opacity: $viewer-opacity-disabled; + transition: $viewer-transition-time; + } + + &--finished { + color: $timer-finished-color; + } + } + + /* =================== OVERLAY ===================*/ + + .end-message { + text-align: center; + font-size: 12vw; + line-height: 0.9em; + font-weight: 600; + color: $timer-finished-color; + padding: 0; + } +} diff --git a/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.tsx b/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.tsx new file mode 100644 index 0000000000..d1e5a65c4b --- /dev/null +++ b/apps/client/src/features/viewers/pop-out-clock/PopOutTimer.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from 'react'; +import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils'; +import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types'; +import { ViewExtendedTimer } from '../../../common/models/TimeManager.type'; +import { useTranslation } from '../../../translation/TranslationProvider'; + + +import './PopOutTimer.scss'; + +interface MinimalTimerProps { + isMirrored: boolean; + time: ViewExtendedTimer; + viewSettings: ViewSettings; + +} + +export default function PopOutClock(props: MinimalTimerProps) { + const { isMirrored, time, viewSettings } = props; + const [ready, setReady] = useState(false); + const [videoSource, setVideoSource] = useState(null); + const canvasRef = useRef(null); + const videoRef = useRef(null); + + const { getLocalizedString } = useTranslation(); + + + + const stageTimer = getTimerByType(false, time); + const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), { + removeSeconds: false, + removeLeadingZero: true, + }); + + let color = "#000000"; + let title = ""; + let clicked = false; + + useEffect(() => { + const canvas = canvasRef.current; + const videoElement = videoRef.current; + if (canvas && videoElement) { + const context = canvas.getContext('2d'); + if (context) { + changeVideo(color, title, context, canvas, videoElement); + } + setReady(true); + } + }, []); + + const openPip = async () => { + if (!videoRef.current) return; + clicked = true; + await videoRef.current.play(); + + if (videoRef.current !== document.pictureInPictureElement) { + try { + await videoRef.current.requestPictureInPicture(); + } catch (error) { + console.error("Error: Unable to enter Picture-in-Picture mode:", error); + } + } else { + try { + await document.exitPictureInPicture(); + } catch (error) { + console.error("Error: Unable to exit Picture-in-Picture mode:", error); + } + } + }; + + const drawFrame = (color: string, text: string, context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => { + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.font = "60px Arial"; + context.fillStyle = "white"; + const textWidth = context.measureText(text).width; + const x = (canvas.width - textWidth) / 2; + const y = canvas.height / 2 + 15; + + context.fillText(text, x, y); + }; + + const createVideoBlob = (canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, callback: (url: string) => void) => { + const stream = canvas.captureStream(30); + const mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' }); + const chunks: BlobPart[] = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunks.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + const blob = new Blob(chunks, { type: 'video/webm' }); + callback(URL.createObjectURL(blob)); + }; + + mediaRecorder.start(); + setTimeout(() => { + mediaRecorder.stop(); + }, 100); + }; + + const changeVideo = ( + color: string, + text: string, + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + videoElement: HTMLVideoElement + ) => { + drawFrame(color, text, context, canvas); + createVideoBlob(canvas, context, (newVideoSource) => { + if (videoSource) { + URL.revokeObjectURL(videoSource); + } + setVideoSource(newVideoSource); + videoElement.src = newVideoSource; + videoElement.play().catch((error) => { + console.error("Error playing video:", error); + }); + }); + }; + + useEffect(() => { + if (ready && canvasRef.current && videoRef.current) { + const canvas = canvasRef.current; + const context = canvas.getContext('2d'); + let i = 0; + const interval = setInterval(() => { + changeVideo("green", display, context!, canvas, videoRef.current!); + i++; + }, 1000); + return () => clearInterval(interval); // Clean up the interval on component unmount + } + }, [ready]); + + return ( +
+
{display}
+ + + +
+ ); +}