mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 03:43:50 +00:00
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:
committed by
GitHub
parent
3dfb49e63f
commit
f223766445
@@ -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;
|
||||
Reference in New Issue
Block a user