ui(components): CinePlayer updated for ui-next (#4932)

This commit is contained in:
Dan Rukas 2025-04-08 12:03:35 -04:00 committed by GitHub
parent f71d370969
commit 1b8729dded
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 325 additions and 13 deletions

View File

@ -1,5 +1,5 @@
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 { useAppConfig } from '@state';
@ -244,4 +244,4 @@ function RenderCinePlayer({
);
}
export default WrappedCinePlayer;
export default WrappedCinePlayer;

View File

@ -1,4 +1,4 @@
import { CinePlayer } from '@ohif/ui';
import { CinePlayer } from '@ohif/ui-next';
import DicomUpload from '../components/DicomUpload/DicomUpload';
export default {

View File

@ -14,17 +14,13 @@ import {
ServiceProvidersManager,
SystemContextProvider,
} from '@ohif/core';
import {
ThemeWrapper,
ViewportDialogProvider,
CineProvider,
UserAuthenticationProvider,
} from '@ohif/ui';
import { ThemeWrapper, ViewportDialogProvider, UserAuthenticationProvider } from '@ohif/ui';
import {
ThemeWrapper as ThemeWrapperNext,
NotificationProvider,
ViewportGridProvider,
DialogProvider,
CineProvider,
TooltipProvider,
Modal as ModalNext,
ManagedDialog,

View 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;

View File

@ -0,0 +1,5 @@
import CinePlayer from './CinePlayer';
import type { CinePlayerProps } from './CinePlayer';
export { CinePlayer, CinePlayerProps };
export default CinePlayer;

View File

@ -312,10 +312,11 @@ interface NumberStepperProps {
className?: string;
children?: React.ReactNode;
direction?: 'horizontal' | 'vertical';
inputWidth?: string;
}
// 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);
if (!ctx) {
throw new Error('NumberStepper must be used inside <Numeric.Container>.');
@ -379,7 +380,10 @@ function NumberStepper({ className, children, direction }: NumberStepperProps) {
value={displayValue}
onChange={handleInputChange}
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}
<RightControl
@ -405,7 +409,8 @@ function NumberStepper({ className, children, direction }: NumberStepperProps) {
onChange={handleInputChange}
onBlur={handleBlur}
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">

View File

@ -29,6 +29,7 @@ import { Combobox } from './Combobox';
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable';
import { Calendar } from './Calendar';
import CinePlayer from './CinePlayer';
import { DatePickerWithRange } from './DateRange';
import { Separator } from './Separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
@ -254,4 +255,5 @@ export {
InputFilter,
WindowLevel,
WindowLevelHistogram,
};
CinePlayer,
};

View 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);

View File

@ -3,9 +3,11 @@ import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './Vi
import { ModalProvider, useModal } from './ModalProvider';
import { DialogProvider, useDialog } from './DialogProvider';
import ManagedDialog from './ManagedDialog';
import CineProvider, { useCine } from './CineProvider';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
export { ModalProvider, useModal };
export { DialogProvider, useDialog };
export { ManagedDialog };
export { CineProvider, useCine };