ui(components): CinePlayer updated for ui-next (#4932)
This commit is contained in:
parent
f71d370969
commit
1b8729dded
@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import { useCine } from '@ohif/ui';
|
import { useCine } from '@ohif/ui-next';
|
||||||
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
|
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
|
||||||
import { useAppConfig } from '@state';
|
import { useAppConfig } from '@state';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { CinePlayer } from '@ohif/ui';
|
import { CinePlayer } from '@ohif/ui-next';
|
||||||
import DicomUpload from '../components/DicomUpload/DicomUpload';
|
import DicomUpload from '../components/DicomUpload/DicomUpload';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@ -14,17 +14,13 @@ import {
|
|||||||
ServiceProvidersManager,
|
ServiceProvidersManager,
|
||||||
SystemContextProvider,
|
SystemContextProvider,
|
||||||
} from '@ohif/core';
|
} from '@ohif/core';
|
||||||
import {
|
import { ThemeWrapper, ViewportDialogProvider, UserAuthenticationProvider } from '@ohif/ui';
|
||||||
ThemeWrapper,
|
|
||||||
ViewportDialogProvider,
|
|
||||||
CineProvider,
|
|
||||||
UserAuthenticationProvider,
|
|
||||||
} from '@ohif/ui';
|
|
||||||
import {
|
import {
|
||||||
ThemeWrapper as ThemeWrapperNext,
|
ThemeWrapper as ThemeWrapperNext,
|
||||||
NotificationProvider,
|
NotificationProvider,
|
||||||
ViewportGridProvider,
|
ViewportGridProvider,
|
||||||
DialogProvider,
|
DialogProvider,
|
||||||
|
CineProvider,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
Modal as ModalNext,
|
Modal as ModalNext,
|
||||||
ManagedDialog,
|
ManagedDialog,
|
||||||
|
|||||||
199
platform/ui-next/src/components/CinePlayer/CinePlayer.tsx
Normal file
199
platform/ui-next/src/components/CinePlayer/CinePlayer.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
|
||||||
|
import { Icons } from '@ohif/ui-next';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '../Popover/Popover';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Numeric } from '../Numeric/Numeric';
|
||||||
|
|
||||||
|
export type CinePlayerProps = {
|
||||||
|
className: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
minFrameRate?: number;
|
||||||
|
maxFrameRate?: number;
|
||||||
|
stepFrameRate?: number;
|
||||||
|
frameRate?: number;
|
||||||
|
onFrameRateChange: (value: number) => void;
|
||||||
|
onPlayPauseChange: (value: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
updateDynamicInfo?: (info: any) => void;
|
||||||
|
dynamicInfo?: {
|
||||||
|
dimensionGroupNumber: number;
|
||||||
|
numDimensionGroups: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CinePlayer: React.FC<CinePlayerProps> = ({
|
||||||
|
className,
|
||||||
|
isPlaying = false,
|
||||||
|
minFrameRate = 1,
|
||||||
|
maxFrameRate = 90,
|
||||||
|
stepFrameRate = 1,
|
||||||
|
frameRate: defaultFrameRate = 24,
|
||||||
|
onFrameRateChange = () => {},
|
||||||
|
onPlayPauseChange = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
dynamicInfo = {},
|
||||||
|
updateDynamicInfo,
|
||||||
|
}) => {
|
||||||
|
const isDynamic = !!dynamicInfo?.numDimensionGroups;
|
||||||
|
const [frameRate, setFrameRate] = useState(defaultFrameRate);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const debouncedSetFrameRate = useCallback(debounce(onFrameRateChange, 100), [onFrameRateChange]);
|
||||||
|
|
||||||
|
const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play');
|
||||||
|
|
||||||
|
const handleSetFrameRate = (frameRate: number) => {
|
||||||
|
if (frameRate < minFrameRate || frameRate > maxFrameRate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFrameRate(frameRate);
|
||||||
|
debouncedSetFrameRate(frameRate);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFrameRate(defaultFrameRate);
|
||||||
|
}, [defaultFrameRate]);
|
||||||
|
|
||||||
|
const handleDimensionGroupNumberChange = useCallback(
|
||||||
|
(newGroupNumber: number) => {
|
||||||
|
if (isDynamic && dynamicInfo) {
|
||||||
|
updateDynamicInfo?.({
|
||||||
|
...dynamicInfo,
|
||||||
|
dimensionGroupNumber: newGroupNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDynamic, dynamicInfo, updateDynamicInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{isDynamic && dynamicInfo && (
|
||||||
|
<Numeric.Container
|
||||||
|
mode="singleRange"
|
||||||
|
min={1}
|
||||||
|
max={dynamicInfo.numDimensionGroups}
|
||||||
|
step={1}
|
||||||
|
value={dynamicInfo.dimensionGroupNumber}
|
||||||
|
onChange={val => handleDimensionGroupNumberChange(val as number)}
|
||||||
|
className="mb-3 w-full"
|
||||||
|
>
|
||||||
|
<Numeric.SingleRange showNumberInput={false} />
|
||||||
|
</Numeric.Container>
|
||||||
|
)}
|
||||||
|
<div className={'bg-muted inline-flex select-none items-center gap-2 rounded-md px-2 py-2'}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onPlayPauseChange(!isPlaying)}
|
||||||
|
data-cy={'cine-player-play-pause'}
|
||||||
|
>
|
||||||
|
<Icons.ByName name={getPlayPauseIconName()} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isDynamic && dynamicInfo && (
|
||||||
|
<div className="min-w-16 max-w-44 text-foreground flex flex-col">
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-foreground w-2">{dynamicInfo.dimensionGroupNumber}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{`/${dynamicInfo.numDimensionGroups}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{dynamicInfo.label}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Popover
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={setPopoverOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-full border-none bg-transparent p-0 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<Numeric.Container
|
||||||
|
mode="stepper"
|
||||||
|
min={minFrameRate}
|
||||||
|
max={maxFrameRate}
|
||||||
|
step={stepFrameRate}
|
||||||
|
value={frameRate}
|
||||||
|
onChange={val => handleSetFrameRate(val as number)}
|
||||||
|
className="border-0 bg-transparent"
|
||||||
|
>
|
||||||
|
<Numeric.NumberStepper
|
||||||
|
direction="horizontal"
|
||||||
|
inputWidth="w-7 max-w-7"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<div className="text-foreground flex-shrink-0 text-center text-sm leading-[22px]">
|
||||||
|
<span className="text-muted-foreground whitespace-nowrap text-xs">
|
||||||
|
{' FPS'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Numeric.NumberStepper>
|
||||||
|
</Numeric.Container>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
|
className="cine-fps-range-popover z-50 w-auto p-2"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<Numeric.Container
|
||||||
|
mode="singleRange"
|
||||||
|
min={minFrameRate}
|
||||||
|
max={maxFrameRate}
|
||||||
|
step={stepFrameRate}
|
||||||
|
value={frameRate}
|
||||||
|
onChange={val => handleSetFrameRate(val as number)}
|
||||||
|
className="h-6 px-2"
|
||||||
|
>
|
||||||
|
<Numeric.SingleRange
|
||||||
|
showNumberInput={false}
|
||||||
|
sliderClassName="w-40"
|
||||||
|
/>
|
||||||
|
</Numeric.Container>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
data-cy={'cine-player-close'}
|
||||||
|
>
|
||||||
|
<Icons.Close />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CinePlayer.propTypes = {
|
||||||
|
/** Minimum value for range slider */
|
||||||
|
minFrameRate: PropTypes.number,
|
||||||
|
/** Maximum value for range slider */
|
||||||
|
maxFrameRate: PropTypes.number,
|
||||||
|
/** Increment range slider can "step" in either direction */
|
||||||
|
stepFrameRate: PropTypes.number,
|
||||||
|
frameRate: PropTypes.number,
|
||||||
|
/** 'true' if playing, 'false' if paused */
|
||||||
|
isPlaying: PropTypes.bool.isRequired,
|
||||||
|
onPlayPauseChange: PropTypes.func,
|
||||||
|
onFrameRateChange: PropTypes.func,
|
||||||
|
onClose: PropTypes.func,
|
||||||
|
isDynamic: PropTypes.bool,
|
||||||
|
dynamicInfo: PropTypes.shape({
|
||||||
|
dimensionGroupNumber: PropTypes.number,
|
||||||
|
numDimensionGroups: PropTypes.number,
|
||||||
|
label: PropTypes.string,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CinePlayer;
|
||||||
5
platform/ui-next/src/components/CinePlayer/index.ts
Normal file
5
platform/ui-next/src/components/CinePlayer/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import CinePlayer from './CinePlayer';
|
||||||
|
import type { CinePlayerProps } from './CinePlayer';
|
||||||
|
|
||||||
|
export { CinePlayer, CinePlayerProps };
|
||||||
|
export default CinePlayer;
|
||||||
@ -312,10 +312,11 @@ interface NumberStepperProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
direction?: 'horizontal' | 'vertical';
|
direction?: 'horizontal' | 'vertical';
|
||||||
|
inputWidth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modified NumberStepper component to properly position left/right controls
|
// Modified NumberStepper component to properly position left/right controls
|
||||||
function NumberStepper({ className, children, direction }: NumberStepperProps) {
|
function NumberStepper({ className, children, direction, inputWidth }: NumberStepperProps) {
|
||||||
const ctx = useContext(NumericMetaContext);
|
const ctx = useContext(NumericMetaContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error('NumberStepper must be used inside <Numeric.Container>.');
|
throw new Error('NumberStepper must be used inside <Numeric.Container>.');
|
||||||
@ -379,7 +380,10 @@ function NumberStepper({ className, children, direction }: NumberStepperProps) {
|
|||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className="h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none"
|
className={cn(
|
||||||
|
"h-6 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none",
|
||||||
|
inputWidth ? inputWidth : "w-12 max-w-12"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<RightControl
|
<RightControl
|
||||||
@ -405,7 +409,8 @@ function NumberStepper({ className, children, direction }: NumberStepperProps) {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none'
|
'h-6 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none',
|
||||||
|
inputWidth ? inputWidth : "w-12 max-w-12"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="ml-1 flex flex-col">
|
<div className="ml-1 flex flex-col">
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { Combobox } from './Combobox';
|
|||||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover';
|
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover';
|
||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable';
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable';
|
||||||
import { Calendar } from './Calendar';
|
import { Calendar } from './Calendar';
|
||||||
|
import CinePlayer from './CinePlayer';
|
||||||
import { DatePickerWithRange } from './DateRange';
|
import { DatePickerWithRange } from './DateRange';
|
||||||
import { Separator } from './Separator';
|
import { Separator } from './Separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
|
||||||
@ -254,4 +255,5 @@ export {
|
|||||||
InputFilter,
|
InputFilter,
|
||||||
WindowLevel,
|
WindowLevel,
|
||||||
WindowLevelHistogram,
|
WindowLevelHistogram,
|
||||||
|
CinePlayer,
|
||||||
};
|
};
|
||||||
103
platform/ui-next/src/contextProviders/CineProvider.tsx
Normal file
103
platform/ui-next/src/contextProviders/CineProvider.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
isCineEnabled: false,
|
||||||
|
cines: {
|
||||||
|
/*
|
||||||
|
* viewportId: { isPlaying: false, frameRate: 24 };
|
||||||
|
*/
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CINE = { isPlaying: false, frameRate: 24 };
|
||||||
|
|
||||||
|
export const CineContext = createContext(null);
|
||||||
|
|
||||||
|
export default function CineProvider({ children, service }) {
|
||||||
|
const reducer = (state, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_CINE': {
|
||||||
|
const { id, frameRate, isPlaying = undefined } = action.payload;
|
||||||
|
const cines = state.cines;
|
||||||
|
|
||||||
|
const syncedCineIds = service.getSyncedViewports(id).map(({ viewportId }) => viewportId);
|
||||||
|
const cineIdsToUpdate = [id, ...syncedCineIds].filter(curId => {
|
||||||
|
const currentCine = cines[curId] ?? {};
|
||||||
|
const shouldUpdateFrameRate =
|
||||||
|
currentCine.frameRate !== (frameRate ?? currentCine.frameRate);
|
||||||
|
const shouldUpdateIsPlaying =
|
||||||
|
currentCine.isPlaying !== (isPlaying ?? currentCine.isPlaying);
|
||||||
|
|
||||||
|
return shouldUpdateFrameRate || shouldUpdateIsPlaying;
|
||||||
|
});
|
||||||
|
|
||||||
|
cineIdsToUpdate.forEach(currId => {
|
||||||
|
let cine = cines[currId];
|
||||||
|
|
||||||
|
if (!cine) {
|
||||||
|
cine = { id, ...DEFAULT_CINE };
|
||||||
|
cines[currId] = cine;
|
||||||
|
}
|
||||||
|
|
||||||
|
cine.frameRate = frameRate ?? cine.frameRate;
|
||||||
|
cine.isPlaying = isPlaying ?? cine.isPlaying;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...state, ...cines };
|
||||||
|
}
|
||||||
|
case 'SET_IS_CINE_ENABLED': {
|
||||||
|
return { ...state, ...{ isCineEnabled: action.payload } };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return action.payload;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(reducer, DEFAULT_STATE);
|
||||||
|
|
||||||
|
const getState = useCallback(() => state, [state]);
|
||||||
|
|
||||||
|
const setIsCineEnabled = useCallback(
|
||||||
|
isCineEnabled => dispatch({ type: 'SET_IS_CINE_ENABLED', payload: isCineEnabled }),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCine = useCallback(
|
||||||
|
({ id, frameRate, isPlaying }) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_CINE',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
frameRate,
|
||||||
|
isPlaying,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the implementation of a modal service that can be used by extensions.
|
||||||
|
*
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (service) {
|
||||||
|
service.setServiceImplementation({ getState, setIsCineEnabled, setCine });
|
||||||
|
}
|
||||||
|
}, [getState, service, setCine, setIsCineEnabled]);
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
getState,
|
||||||
|
setCine,
|
||||||
|
setIsCineEnabled: isCineEnabled => service.setIsCineEnabled(isCineEnabled),
|
||||||
|
playClip: (element, playClipOptions) => service.playClip(element, playClipOptions),
|
||||||
|
stopClip: (element, stopClipOptions) => service.stopClip(element, stopClipOptions),
|
||||||
|
setViewportCineClosed: viewportId => service.setViewportCineClosed(viewportId),
|
||||||
|
clearViewportCineClosed: viewportId => service.clearViewportCineClosed(viewportId),
|
||||||
|
isViewportCineClosed: viewportId => service.isViewportCineClosed(viewportId),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <CineContext.Provider value={[state, api]}>{children}</CineContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCine = () => useContext(CineContext);
|
||||||
@ -3,9 +3,11 @@ import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './Vi
|
|||||||
import { ModalProvider, useModal } from './ModalProvider';
|
import { ModalProvider, useModal } from './ModalProvider';
|
||||||
import { DialogProvider, useDialog } from './DialogProvider';
|
import { DialogProvider, useDialog } from './DialogProvider';
|
||||||
import ManagedDialog from './ManagedDialog';
|
import ManagedDialog from './ManagedDialog';
|
||||||
|
import CineProvider, { useCine } from './CineProvider';
|
||||||
|
|
||||||
export { useNotification, NotificationProvider };
|
export { useNotification, NotificationProvider };
|
||||||
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
|
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
|
||||||
export { ModalProvider, useModal };
|
export { ModalProvider, useModal };
|
||||||
export { DialogProvider, useDialog };
|
export { DialogProvider, useDialog };
|
||||||
export { ManagedDialog };
|
export { ManagedDialog };
|
||||||
|
export { CineProvider, useCine };
|
||||||
Loading…
Reference in New Issue
Block a user