Skip to content

Commit

Permalink
Merge pull request #192 from BinaryStudioAcademy/task/OV-162-play-but…
Browse files Browse the repository at this point in the history
…ton-control-cursor

OV-162: Play button control cursor
  • Loading branch information
nikita-remeslov authored Sep 11, 2024
2 parents 12278ef + 6cc92ea commit c3476f8
Show file tree
Hide file tree
Showing 12 changed files with 130 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type VideoDuration,
} from '~/bundles/common/components/upload-video/components/video-player/libs/types/types.js';
import {
useAnimationFrame,
useCallback,
useEffect,
useState,
Expand All @@ -30,7 +31,6 @@ import {
} from './libs/constants/constants.js';
import { VideoEvent } from './libs/enums/enums.js';
import { getTime } from './libs/helpers/helpers.js';
import { useAnimationFrame } from './libs/hooks/hooks.js';

type Properties = {
videoPlayerReference: React.RefObject<PlayerRef>;
Expand Down

This file was deleted.

1 change: 1 addition & 0 deletions frontend/src/bundles/common/hooks/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { useAnimationFrame } from './use-animation-frame/use-animation-frame.js';
export { useAppDispatch } from './use-app-dispatch/use-app-dispatch.hook.js';
export { useAppForm } from './use-app-form/use-app-form.hook.js';
export { useAppSelector } from './use-app-selector/use-app-selector.hook.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { type TypedUseSelectorHook, useSelector } from 'react-redux';

import { type store } from '~/framework/store/store.js';
import { type RootState } from '~/bundles/common/types/types.js';

const useAppSelector: TypedUseSelectorHook<
ReturnType<typeof store.instance.getState>
> = useSelector;
const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

export { useAppSelector };
5 changes: 5 additions & 0 deletions frontend/src/bundles/common/types/root-state.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { type store } from '~/framework/store/store.js';

type RootState = ReturnType<typeof store.instance.getState>;

export { type RootState };
1 change: 1 addition & 0 deletions frontend/src/bundles/common/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { type AsyncThunkConfig } from './async-thunk-config.type.js';
export { type RootState } from './root-state.type.js';
export { type VideoPreview } from './video-preview.type.js';
export {
type ServerErrorDetail,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { format, secondsToMilliseconds } from 'date-fns';
import { format } from 'date-fns';

import { Flex, Text } from '~/bundles/common/components/components.js';
import { useAppSelector } from '~/bundles/common/hooks/hooks.js';
import { selectTotalDuration } from '~/bundles/studio/store/selectors.js';

type Properties = {
currentTime: number;
duration: number;
};
const TimeDisplay: React.FC = () => {
const { elapsedTime } = useAppSelector(({ studio }) => ({
elapsedTime: studio.player.elapsedTime,
}));
const totalDuration = useAppSelector(selectTotalDuration);

const TimeDisplay: React.FC<Properties> = ({ currentTime, duration }) => {
return (
<Flex gap="2px" position="absolute" left="100%" marginLeft="15px">
<Text color="typography.900" variant="caption">
{format(new Date(secondsToMilliseconds(currentTime)), 'mm:ss')}
{format(new Date(elapsedTime), 'mm:ss')}
</Text>
<Text color="background.50" variant="caption">
/
</Text>
<Text color="background.50" variant="caption">
{format(new Date(secondsToMilliseconds(duration)), 'mm:ss')}
{format(new Date(totalDuration), 'mm:ss')}
</Text>
</Flex>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import { Flex } from '~/bundles/common/components/components.js';
import { useCallback, useState } from '~/bundles/common/hooks/hooks.js';
import {
useAppDispatch,
useAppSelector,
useCallback,
} from '~/bundles/common/hooks/hooks.js';
import { IconName, IconSize } from '~/bundles/common/icons/icons.js';
import { selectTotalDuration } from '~/bundles/studio/store/selectors.js';
import { actions as studioActions } from '~/bundles/studio/store/studio.js';

import { Control, TimeDisplay } from './components/components.js';

const PlayerControls: React.FC = () => {
// Mocked data. Update later
const currentTime = 5;
const duration = 10;
const dispatch = useAppDispatch();
const { isPlaying, elapsedTime } = useAppSelector(({ studio }) => ({
isPlaying: studio.player.isPlaying,
elapsedTime: studio.player.elapsedTime,
}));
const totalDuration = useAppSelector(selectTotalDuration);

const [isPlaying, setIsPlaying] = useState<boolean>(false);
const handleTogglePlaying = useCallback((): void => {
if (elapsedTime >= totalDuration) {
void dispatch(studioActions.setElapsedTime(0));
}

const handleClick = useCallback((): void => {
setIsPlaying((previous) => !previous);
}, []);
void dispatch(studioActions.setPlaying(!isPlaying));
}, [elapsedTime, totalDuration, dispatch, isPlaying]);

return (
<Flex
Expand All @@ -34,7 +45,7 @@ const PlayerControls: React.FC = () => {
label={isPlaying ? 'Pause' : 'Play video'}
size={IconSize.SMALL}
icon={isPlaying ? IconName.PAUSE : IconName.PLAY}
onClick={handleClick}
onClick={handleTogglePlaying}
/>

<Control
Expand All @@ -43,7 +54,7 @@ const PlayerControls: React.FC = () => {
icon={IconName.PLAY_STEP_NEXT}
/>

<TimeDisplay currentTime={currentTime} duration={duration} />
<TimeDisplay />
</Flex>
</Flex>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,57 +1,67 @@
import { Box } from '~/bundles/common/components/components.js';
import {
useAnimationFrame,
useAppDispatch,
useAppSelector,
useCallback,
useEffect,
useLayoutEffect,
useRef as useReference,
useState,
} from '~/bundles/common/hooks/hooks.js';
import { useTimelineContext } from '~/bundles/studio/hooks/hooks.js';
import { selectTotalDuration } from '~/bundles/studio/store/selectors.js';
import { actions as studioActions } from '~/bundles/studio/store/studio.js';
import styles from '~/framework/theme/styles/css-modules/timeline.module.css';

type Properties = {
interval?: number;
};
const TimeCursor: React.FC = () => {
const dispatch = useAppDispatch();
const { isPlaying, elapsedTime } = useAppSelector(({ studio }) => ({
isPlaying: studio.player.isPlaying,
elapsedTime: studio.player.elapsedTime,
}));
const totalDuration = useAppSelector(selectTotalDuration);

const TimeCursor: React.FC<Properties> = ({ interval }) => {
const timeCursorReference = useReference<HTMLDivElement>(null);
const renderTimeReference = useReference(Date.now());
const { range, direction, sidebarWidth, valueToPixels, pixelsToValue } =
const renderTimeReference = useReference(0);
const { direction, sidebarWidth, valueToPixels, pixelsToValue } =
useTimelineContext();

const side = direction === 'rtl' ? 'right' : 'left';
const millisecondPerRefresh = 1000;

const [isDragging, setIsDragging] = useState(false);
const [cursorPosition, setCursorPosition] = useState<number | null>(null);

useLayoutEffect(() => {
const offsetCursor = (): void => {
if (!timeCursorReference.current || cursorPosition !== null) {
return;
}
const timeDelta = Date.now() - renderTimeReference.current;
const timeDeltaInPixels = valueToPixels(timeDelta);
useEffect(() => {
if (elapsedTime >= totalDuration) {
void dispatch(studioActions.setPlaying(false));
}
}, [dispatch, elapsedTime, totalDuration]);

const sideDelta = sidebarWidth + timeDeltaInPixels;
timeCursorReference.current.style[side] = `${sideDelta}px`;
};
offsetCursor();
const cursorUpdateInterval = setInterval(
offsetCursor,
interval ?? millisecondPerRefresh,
);
return () => {
clearInterval(cursorUpdateInterval);
};
}, [
side,
sidebarWidth,
interval,
range.start,
valueToPixels,
cursorPosition,
renderTimeReference,
timeCursorReference,
]);
const offsetCursor = (): void => {
if (!timeCursorReference.current || cursorPosition) {
return;
}

const currentTime = Date.now();
const timeDelta =
currentTime - renderTimeReference.current + elapsedTime;
const timeDeltaInPixels = valueToPixels(timeDelta);

const sideDelta = sidebarWidth + timeDeltaInPixels;
timeCursorReference.current.style[side] = `${sideDelta}px`;

dispatch(studioActions.setElapsedTime(timeDelta));
renderTimeReference.current = currentTime;
};

useAnimationFrame(offsetCursor, isPlaying);

useEffect(() => {
if (isPlaying) {
renderTimeReference.current = Date.now();
}
}, [cursorPosition, renderTimeReference, isPlaying]);

useLayoutEffect(() => {
const handleMouseMove = (event: MouseEvent): void => {
Expand All @@ -60,14 +70,20 @@ const TimeCursor: React.FC<Properties> = ({ interval }) => {
}

const newCursorPosition = event.clientX - sidebarWidth;

const newCursorPositionInTime = pixelsToValue(newCursorPosition);
dispatch(studioActions.setElapsedTime(newCursorPositionInTime));

setCursorPosition(newCursorPosition);
};

const handleMouseUp = (event: MouseEvent): void => {
setIsDragging(false);
const newCursorPosition = event.clientX - sidebarWidth;
renderTimeReference.current =
Date.now() - pixelsToValue(newCursorPosition);
const newCursorPositionInTime = pixelsToValue(newCursorPosition);
renderTimeReference.current = Date.now() - newCursorPositionInTime;

dispatch(studioActions.setElapsedTime(newCursorPositionInTime));
setCursorPosition(null);
};

Expand All @@ -90,10 +106,11 @@ const TimeCursor: React.FC<Properties> = ({ interval }) => {
pixelsToValue,
renderTimeReference,
timeCursorReference,
dispatch,
]);

useLayoutEffect(() => {
if (cursorPosition !== null && timeCursorReference.current) {
if (cursorPosition && timeCursorReference.current) {
timeCursorReference.current.style[side] =
`${cursorPosition + sidebarWidth}px`;
}
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/bundles/studio/store/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createSelector } from '@reduxjs/toolkit';
import { secondsToMilliseconds } from 'date-fns';

import { type RootState } from '~/bundles/common/types/types.js';

import { type Scene } from '../types/types.js';

const selectScenes = (state: RootState): Scene[] => state.studio.scenes;

const selectTotalDuration = createSelector([selectScenes], (scenes) => {
const totalDuration = scenes.reduce(
(total, scene) => total + scene.duration,
0,
);

return secondsToMilliseconds(totalDuration);
});

export { selectTotalDuration };
15 changes: 15 additions & 0 deletions frontend/src/bundles/studio/store/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ type DestinationPointerActionPayload = ItemActionPayload & {
};

type State = {
player: {
isPlaying: boolean;
elapsedTime: number; // ms
};
avatars: {
dataStatus: ValueOf<typeof DataStatus>;
items: Array<AvatarGetResponseDto> | [];
Expand All @@ -60,6 +64,10 @@ type State = {
};

const initialState: State = {
player: {
isPlaying: false,
elapsedTime: 0,
},
avatars: {
dataStatus: DataStatus.IDLE,
items: [],
Expand Down Expand Up @@ -103,6 +111,13 @@ const { reducer, actions, name } = createSlice({
(script) => script.id !== action.payload,
);
},

setPlaying(state, action: PayloadAction<boolean>) {
state.player.isPlaying = action.payload;
},
setElapsedTime(state, action: PayloadAction<number>) {
state.player.elapsedTime = action.payload;
},
reorderScripts(state, action: PayloadAction<ItemActionPayload>) {
const { id, span } = action.payload;

Expand Down

0 comments on commit c3476f8

Please sign in to comment.