mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-06 07:53:54 +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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <IoArrowUp className={cx([style.corner, className])} {...elementProps} />;
|
||||
export function CornerExtract({ className, ...elementProps }: IconBaseProps) {
|
||||
return <IoArrowUp className={cx([style.corner, style.arrow, className])} {...elementProps} />;
|
||||
}
|
||||
|
||||
export function CornerPipButton({ className, ...elementProps }: IconBaseProps) {
|
||||
return <TbPictureInPictureOff className={cx([style.corner, style.offsetCorner, className])} {...elementProps} />;
|
||||
}
|
||||
|
||||
interface ExtractAndPip extends IconBaseProps {
|
||||
onExtractClick: MouseEventHandler<SVGElement>;
|
||||
pipElement: JSX.Element;
|
||||
}
|
||||
|
||||
export function CornerWithPip({ className, pipElement, onExtractClick }: ExtractAndPip) {
|
||||
return (
|
||||
<>
|
||||
<IoArrowUp className={cx([style.corner, style.arrow, className])} onClick={onExtractClick} />
|
||||
{/* the pip element returns the icon button */}
|
||||
{pipElement}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Title({ children, className, ...elementProps }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
|
||||
@@ -19,6 +19,11 @@ declare global {
|
||||
process: {
|
||||
type: string;
|
||||
};
|
||||
// Experimental browser feature
|
||||
documentPictureInPicture: {
|
||||
requestWindow: () => Promise<Window>;
|
||||
window: Window;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={style.messages} data-testid='panel-messages-control'>
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
|
||||
<div className={classes}>
|
||||
|
||||
@@ -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 (
|
||||
<div className={style.preview}>
|
||||
<Corner onClick={(event) => handleLinks('timer', event)} />
|
||||
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||
<div className={contentClasses}>
|
||||
<div
|
||||
className={style.mainContent}
|
||||
|
||||
@@ -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';
|
||||
@@ -18,7 +18,7 @@ function TimerControlExport() {
|
||||
return (
|
||||
<ProtectRoute permission='editor'>
|
||||
<div className={style.playback} data-testid='panel-timer-control'>
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('timercontrol', event)} />}
|
||||
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('timercontrol', event)} />}
|
||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||
|
||||
<div className={style.content}>
|
||||
|
||||
@@ -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() {
|
||||
<div className={style.rundown}>
|
||||
<div className={style.list}>
|
||||
<ErrorBoundary>
|
||||
{!isExtracted && <Corner onClick={(event) => handleLinks('rundown', event)} />}
|
||||
{!isExtracted && <CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||
<RundownContextMenu>
|
||||
<RundownWrapper />
|
||||
</RundownContextMenu>
|
||||
|
||||
@@ -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