From f2237664456c956aaa0e57e7ef55ee2ad8a2953d Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Fri, 21 Nov 2025 01:00:16 +0800 Subject: [PATCH] feat: pip for timer preview (#1862) --------- Co-authored-by: Shobhit Nagpal <74096450+Shobhit-Nagpal@users.noreply.github.com> Co-authored-by: Carlos Valente --- .../editor-utils/EditorUtils.module.scss | 11 +- .../components/editor-utils/EditorUtils.tsx | 26 +++- apps/client/src/declarations/declaration.d.ts | 5 + .../control/message/MessageControlExport.tsx | 4 +- .../features/control/message/TimerPreview.tsx | 5 +- .../control/playback/TimerControlExport.tsx | 4 +- .../src/features/rundown/RundownExport.tsx | 4 +- .../src/views/editor/pip-timer/PipHost.tsx | 65 +++++++++ .../src/views/editor/pip-timer/PipRoot.tsx | 18 +++ .../src/views/editor/pip-timer/PipTimer.scss | 134 ++++++++++++++++++ .../src/views/editor/pip-timer/PipTimer.tsx | 110 ++++++++++++++ .../src/views/editor/pip-timer/pip.utils.ts | 1 + 12 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 apps/client/src/views/editor/pip-timer/PipHost.tsx create mode 100644 apps/client/src/views/editor/pip-timer/PipRoot.tsx create mode 100644 apps/client/src/views/editor/pip-timer/PipTimer.scss create mode 100644 apps/client/src/views/editor/pip-timer/PipTimer.tsx create mode 100644 apps/client/src/views/editor/pip-timer/pip.utils.ts diff --git a/apps/client/src/common/components/editor-utils/EditorUtils.module.scss b/apps/client/src/common/components/editor-utils/EditorUtils.module.scss index 0a68e8dd1..4a7810547 100644 --- a/apps/client/src/common/components/editor-utils/EditorUtils.module.scss +++ b/apps/client/src/common/components/editor-utils/EditorUtils.module.scss @@ -1,6 +1,7 @@ -.corner { +.arrow { transform: rotate(45deg); - +} +.corner { position: absolute; top: 0.5rem; right: 0.5rem; @@ -21,6 +22,10 @@ } } +.offsetCorner { + right: 2rem; +} + .header { font-size: 1.5rem; } @@ -51,6 +56,6 @@ &.vertical { width: 1px; - height: 0.75em; + height: 0.75em; } } diff --git a/apps/client/src/common/components/editor-utils/EditorUtils.tsx b/apps/client/src/common/components/editor-utils/EditorUtils.tsx index 4737fe905..0e83753a8 100644 --- a/apps/client/src/common/components/editor-utils/EditorUtils.tsx +++ b/apps/client/src/common/components/editor-utils/EditorUtils.tsx @@ -1,13 +1,33 @@ -import type { HTMLAttributes, LabelHTMLAttributes } from 'react'; +import type { HTMLAttributes, JSX, LabelHTMLAttributes, MouseEventHandler } from 'react'; import { IconBaseProps } from 'react-icons'; import { IoArrowUp } from 'react-icons/io5'; +import { TbPictureInPictureOff } from 'react-icons/tb'; import { cx } from '../../utils/styleUtils'; import style from './EditorUtils.module.scss'; -export function Corner({ className, ...elementProps }: IconBaseProps) { - return ; +export function CornerExtract({ className, ...elementProps }: IconBaseProps) { + return ; +} + +export function CornerPipButton({ className, ...elementProps }: IconBaseProps) { + return ; +} + +interface ExtractAndPip extends IconBaseProps { + onExtractClick: MouseEventHandler; + pipElement: JSX.Element; +} + +export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) { + return ( + <> + + {/* the pip element returns the icon button */} + {pipElement} + + ); } export function Title({ children, className, ...elementProps }: HTMLAttributes) { diff --git a/apps/client/src/declarations/declaration.d.ts b/apps/client/src/declarations/declaration.d.ts index 899dc0b67..94933ffae 100644 --- a/apps/client/src/declarations/declaration.d.ts +++ b/apps/client/src/declarations/declaration.d.ts @@ -19,6 +19,11 @@ declare global { process: { type: string; }; + // Experimental browser feature + documentPictureInPicture: { + requestWindow: () => Promise; + window: Window; + }; } } diff --git a/apps/client/src/features/control/message/MessageControlExport.tsx b/apps/client/src/features/control/message/MessageControlExport.tsx index edc9e2d4d..9ff3185cc 100644 --- a/apps/client/src/features/control/message/MessageControlExport.tsx +++ b/apps/client/src/features/control/message/MessageControlExport.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; -import { Corner } from '../../../common/components/editor-utils/EditorUtils'; +import { CornerExtract } from '../../../common/components/editor-utils/EditorUtils'; import ErrorBoundary from '../../../common/components/error-boundary/ErrorBoundary'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; @@ -20,7 +20,7 @@ function MessageControlExport() { return (
- {!isExtracted && handleLinks('messagecontrol', event)} />} + {!isExtracted && handleLinks('messagecontrol', event)} />} {isExtracted && }
diff --git a/apps/client/src/features/control/message/TimerPreview.tsx b/apps/client/src/features/control/message/TimerPreview.tsx index 9b9271d8e..11954d885 100644 --- a/apps/client/src/features/control/message/TimerPreview.tsx +++ b/apps/client/src/features/control/message/TimerPreview.tsx @@ -2,12 +2,13 @@ import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5'; import { LuArrowDownToLine } from 'react-icons/lu'; import { TimerPhase, TimerType } from 'ontime-types'; -import { Corner } from '../../../common/components/editor-utils/EditorUtils'; +import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useMessagePreview } from '../../../common/hooks/useSocket'; import useViewSettings from '../../../common/hooks-query/useViewSettings'; import { handleLinks } from '../../../common/utils/linkUtils'; import { cx, timerPlaceholder } from '../../../common/utils/styleUtils'; +import PipRoot from '../../../views/editor/pip-timer/PipRoot'; import style from './MessageControl.module.scss'; @@ -52,7 +53,7 @@ export default function TimerPreview() { return (
- handleLinks('timer', event)} /> + handleLinks('timer', event)} pipElement={} />
- {!isExtracted && handleLinks('timercontrol', event)} />} + {!isExtracted && handleLinks('timercontrol', event)} />} {isExtracted && }
diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index a41c413e6..df14476de 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { useSessionStorage } from '@mantine/hooks'; -import { Corner } from '../../common/components/editor-utils/EditorUtils'; +import { CornerExtract } from '../../common/components/editor-utils/EditorUtils'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; @@ -60,7 +60,7 @@ function RundownExport() {
- {!isExtracted && handleLinks('rundown', event)} />} + {!isExtracted && handleLinks('rundown', event)} />} diff --git a/apps/client/src/views/editor/pip-timer/PipHost.tsx b/apps/client/src/views/editor/pip-timer/PipHost.tsx new file mode 100644 index 000000000..18e332ecd --- /dev/null +++ b/apps/client/src/views/editor/pip-timer/PipHost.tsx @@ -0,0 +1,65 @@ +import { createRoot } from 'react-dom/client'; +import { ErrorBoundary } from '@sentry/react'; + +import { CornerPipButton } from '../../../common/components/editor-utils/EditorUtils'; +import useViewSettings from '../../../common/hooks-query/useViewSettings'; + +import { PipTimer } from './PipTimer'; + +export default function PipTimerHost() { + const { data, status } = useViewSettings(); + + const openPictureInPicture = async () => { + if (window.documentPictureInPicture.window) { + return; + } + + let pipWindow: Window; + try { + pipWindow = await window.documentPictureInPicture.requestWindow(); + } catch (err) { + console.error('Failed to open Picture-in-Picture:', err); + return; + } + + [...document.styleSheets].forEach((sheet) => { + try { + if (sheet.href) { + const link = pipWindow.document.createElement('link'); + link.rel = 'stylesheet'; + link.href = sheet.href; + pipWindow.document.head.appendChild(link); + } else if (sheet.cssRules) { + const style = pipWindow.document.createElement('style'); + style.textContent = [...sheet.cssRules].map((rule) => rule.cssText).join(''); + pipWindow.document.head.appendChild(style); + } + } catch (e) { + console.warn('Stylesheet copy blocked:', e); + } + }); + + const pipDiv = pipWindow.document.createElement('div'); + pipDiv.setAttribute('id', 'pip-root'); + pipDiv.style.height = '100vh'; + pipWindow.document.body.append(pipDiv); + + const pipRoot = createRoot(pipWindow.document.getElementById('pip-root') as Element, { + onCaughtError: (err, _errInfo) => console.error(err), + onUncaughtError: (err, _errInfo) => console.error(err), + onRecoverableError: (err, _errInfo) => console.error(err), + }); + + pipWindow.addEventListener('pagehide', () => { + pipRoot.unmount(); + }); + + pipRoot.render( + + + , + ); + }; + + return ; +} diff --git a/apps/client/src/views/editor/pip-timer/PipRoot.tsx b/apps/client/src/views/editor/pip-timer/PipRoot.tsx new file mode 100644 index 000000000..7c3b7ce70 --- /dev/null +++ b/apps/client/src/views/editor/pip-timer/PipRoot.tsx @@ -0,0 +1,18 @@ +import { lazy, memo, Suspense } from 'react'; + +import { isPipSupported } from './pip.utils'; + +const PipTimerHost = lazy(() => import('./PipHost')); + +export default memo(PipRoot); +function PipRoot() { + if (!isPipSupported) { + return null; + } + + return ( + + + + ); +} diff --git a/apps/client/src/views/editor/pip-timer/PipTimer.scss b/apps/client/src/views/editor/pip-timer/PipTimer.scss new file mode 100644 index 000000000..2a735d371 --- /dev/null +++ b/apps/client/src/views/editor/pip-timer/PipTimer.scss @@ -0,0 +1,134 @@ +@use '../../../theme/viewerDefs' as *; + +/* The styles in this file are a stripped down version of the timer */ +.pip-timer { + margin: 0; + padding: 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; + + font-family: $viewer-font-family; + background: $viewer-background-color; + color: $viewer-color; + gap: $view-element-gap; + + display: flex; + flex-direction: column; + + &--finished { + outline: clamp(4px, 1vw, 16px) solid $timer-finished-color; + outline-offset: calc(clamp(4px, 1vw, 16px) * -1); + transition: $viewer-transition-time; + } + + /* =================== MAIN ===================*/ + + .timer-container { + flex: 1; + align-content: center; + justify-self: center; + align-self: center; + + width: 100%; + overflow: hidden; + + .timer { + opacity: 1; + font-family: var(--timer-font, $viewer-font-family); + color: var(--timer-colour, $ui-white); + line-height: 0.9em; + text-align: center; + letter-spacing: 0.05em; + font-weight: 600; + + transition-property: font-size; + transition-duration: $viewer-transition-time; + + &--paused { + opacity: $viewer-opacity-disabled; + transition: $viewer-transition-time; + } + + // use a class instead of a phase, to allow suppressing overtime style + &--finished { + color: var(--timer-overtime-color-override, $timer-finished-color); + } + + &[data-phase='warning'] { + color: var(--timer-colour, var(--timer-warning-color-override)); + } + &[data-phase='danger'] { + color: var(--timer-colour, var(--timer-danger-color-override)); + } + &[data-type='none'] { + transition: 1s; + opacity: 0; + } + } + } + + .secondary { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + margin-top: 0.125em; + padding-block: 0.125em; + + font-weight: 600; + text-align: center; + color: var(--external-color-override, $external-color); + letter-spacing: 0.5px; + line-height: 1em; + transition-property: opacity, height; + transition-duration: $viewer-transition-time; + border-top: 1px solid color-mix(in srgb, $external-color 10%, transparent); + + &--hidden { + opacity: 0; + height: 0; + } + } + + .progress-container { + width: 100%; + margin: 0 auto; + opacity: 1; + transition: $viewer-transition-time; + + &--paused { + opacity: $viewer-opacity-disabled; + transition: $viewer-transition-time; + } + } + + /* =================== OVERLAY ===================*/ + + .message-overlay { + position: fixed; + inset: 0; + padding: 2vw; + background: $viewer-background-color; + opacity: 0; + transition: opacity $viewer-transition-time; + + &--active { + z-index: $zindex-floating; + opacity: 1; + } + } + + .message { + display: grid; + place-content: center; + height: 100%; + width: 100%; + + color: $viewer-color; + text-align: center; + font-weight: 600; + } +} diff --git a/apps/client/src/views/editor/pip-timer/PipTimer.tsx b/apps/client/src/views/editor/pip-timer/PipTimer.tsx new file mode 100644 index 000000000..c737c22bb --- /dev/null +++ b/apps/client/src/views/editor/pip-timer/PipTimer.tsx @@ -0,0 +1,110 @@ +import { ViewSettings } from 'ontime-types'; + +import { FitText } from '../../../common/components/fit-text/FitText'; +import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar'; +import { useTimerSocket } from '../../../common/hooks/useSocket'; +import { cx } from '../../../common/utils/styleUtils'; +import { getFormattedTimer, getTimerByType } from '../../../features/viewers/common/viewUtils'; +import { + getEstimatedFontSize, + getIsPlaying, + getSecondaryDisplay, + getShowMessage, + getShowModifiers, + getShowProgressBar, + getTotalTime, +} from '../../timer/timer.utils'; +import { getTimerColour } from '../../utils/presentation.utils'; + +import './PipTimer.scss'; + +interface PipTimerProps { + viewSettings: ViewSettings; +} + +export function PipTimer({ viewSettings }: PipTimerProps) { + const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket(); + + // gather modifiers + const showOverlay = getShowMessage(message.timer); + const { showFinished, showWarning, showDanger } = getShowModifiers( + timerTypeNow, + countToEndNow, + time.phase, + false, + '', + false, + ); + const isPlaying = getIsPlaying(time.playback); + const showProgressBar = getShowProgressBar(timerTypeNow); + + // gather timer data + const totalTime = getTotalTime(time.duration, time.addedTime); + const stageTimer = getTimerByType(false, timerTypeNow, countToEndNow, clock, time, timerTypeNow); + const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', { + removeSeconds: false, + removeLeadingZero: false, + }); + + const currentAux = (() => { + if (message.timer.secondarySource === 'aux1') { + return auxTimer.aux1; + } + if (message.timer.secondarySource === 'aux2') { + return auxTimer.aux2; + } + if (message.timer.secondarySource === 'aux3') { + return auxTimer.aux3; + } + return null; + })(); + + const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, false, false); + + // gather presentation styles + const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger); + const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent); + const userStyles = { + ...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }), + }; + + return ( +
+
+ + {message.timer.text} + +
+ +
+
+ {display} +
+
+ {secondaryContent} +
+
+ + {showProgressBar && ( + + )} +
+ ); +} diff --git a/apps/client/src/views/editor/pip-timer/pip.utils.ts b/apps/client/src/views/editor/pip-timer/pip.utils.ts new file mode 100644 index 000000000..4955ad3d4 --- /dev/null +++ b/apps/client/src/views/editor/pip-timer/pip.utils.ts @@ -0,0 +1 @@ +export const isPipSupported = 'documentPictureInPicture' in window;