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;