feat: pip for timer preview (#1862)

---------

Co-authored-by: Shobhit Nagpal <74096450+Shobhit-Nagpal@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
Alex Christoffer Rasmussen
2025-11-21 01:00:16 +08:00
committed by GitHub
parent 3dfb49e63f
commit f223766445
12 changed files with 373 additions and 14 deletions
@@ -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(
<ErrorBoundary>
<PipTimer viewSettings={data} />
</ErrorBoundary>,
);
};
return <CornerPipButton onClick={status === 'success' ? openPictureInPicture : undefined} />;
}
@@ -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 (
<Suspense fallback={null}>
<PipTimerHost />
</Suspense>
);
}
@@ -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;
}
}
@@ -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 (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
<div className='timer-container'>
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
data-phase={time.phase}
>
{display}
</div>
<div
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
</div>
</div>
{showProgressBar && (
<MultiPartProgressBar
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
danger={eventNow?.timeDanger}
dangerColor={viewSettings.dangerColor}
hideOvertime={!showFinished}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export const isPipSupported = 'documentPictureInPicture' in window;