diff --git a/src/globalKeys.ts b/src/globalKeys.ts index 4f2916476..661b909cc 100644 --- a/src/globalKeys.ts +++ b/src/globalKeys.ts @@ -57,6 +57,14 @@ export const KEYMAP: IKeyMap = { name: "keyboardControls.videoPlayButton", key: "Shift+Alt+Space, Space", }, + previous: { + name: "video.previousButton", + key: "Shift+Alt+Left", + }, + next: { + name: "video.nextButton", + key: "Shift+Alt+Right", + }, preview: { name: "video.previewButton", key: "Shift+Alt+p", diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index 78b60cfed..71c686e56 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -33,6 +33,10 @@ "previewButton-aria": "Vorschaumodus aktivieren oder deaktivieren. Hotkey: {{hotkeyName}}.", "playButton-tooltip": "Video wiedergeben", "pauseButton-tooltip": "Video pausieren", + "previousButton": "Zurück", + "previousButton-tooltip": "Zurück. Hotkey: {{hotkeyName}}.", + "nextButton": "Weiter", + "nextButton-tooltip": "Weiter. Hotkey: {{hotkeyName}}.", "current-time-tooltip": "Aktuelle Zeit", "time-duration-tooltip": "Videodauer", "duration-aria": "Dauer", diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index f468b2512..06c57d6a8 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -35,6 +35,10 @@ "previewButton-aria": "Enable or disable preview mode. Hotkey: {{hotkeyName}}.", "playButton-tooltip": "Play video", "pauseButton-tooltip": "Pause video", + "previousButton": "Back", + "previousButton-tooltip": "Back. Hotkey: {{hotkeyName}}.", + "nextButton": "Forward", + "nextButton-tooltip": "Forward. Hotkey: {{hotkeyName}}.", "current-time-tooltip": "Current time", "time-duration-tooltip": "Video duration", "duration-aria": "Duration", diff --git a/src/main/Cutting.tsx b/src/main/Cutting.tsx index 114e47e2c..4ca30322c 100644 --- a/src/main/Cutting.tsx +++ b/src/main/Cutting.tsx @@ -16,6 +16,8 @@ import { setIsMuted, setVolume, setIsPlayPreview, + jumpToPreviousSegment, + jumpToNextSegment, } from "../redux/videoSlice"; import { useTranslation } from "react-i18next"; import { useAppDispatch, useAppSelector } from "../redux/store"; @@ -108,6 +110,8 @@ const Cutting: React.FC = () => { setIsMuted={setIsMuted} setVolume={setVolume} setIsPlayPreview={setIsPlayPreview} + jumpToPreviousSegment={jumpToPreviousSegment} + jumpToNextSegment={jumpToNextSegment} /> diff --git a/src/main/SubtitleVideoArea.tsx b/src/main/SubtitleVideoArea.tsx index 846139de3..63c0ce010 100644 --- a/src/main/SubtitleVideoArea.tsx +++ b/src/main/SubtitleVideoArea.tsx @@ -17,7 +17,15 @@ import { setIsPlayPreview, setCurrentlyAtAndTriggerPreview, } from "../redux/subtitleSlice"; -import { selectIsMuted, selectVideos, selectVolume, setIsMuted, setVolume } from "../redux/videoSlice"; +import { + selectIsMuted, + selectVideos, + selectVolume, + selectJumpTriggered, + setIsMuted, + setVolume, + setJumpTriggered, +} from "../redux/videoSlice"; import { Flavor } from "../types"; import { settings } from "../config"; import { useTranslation } from "react-i18next"; @@ -128,11 +136,13 @@ const SubtitleVideoArea: React.FC = () => { selectCurrentlyAtInSeconds={selectCurrentlyAtInSeconds} selectPreviewTriggered={selectPreviewTriggered} selectClickTriggered={selectClickTriggered} + selectJumpTriggered={selectJumpTriggered} selectAspectRatio={selectAspectRatio} setIsPlaying={setIsPlaying} selectVolume={selectVolume} setPreviewTriggered={setPreviewTriggered} setClickTriggered={setClickTriggered} + setJumpTriggered={setJumpTriggered} setCurrentlyAt={setCurrentlyAtAndTriggerPreview} setAspectRatio={setAspectRatio} /> diff --git a/src/main/Thumbnail.tsx b/src/main/Thumbnail.tsx index 67fe85a25..bd2b4d830 100644 --- a/src/main/Thumbnail.tsx +++ b/src/main/Thumbnail.tsx @@ -32,6 +32,8 @@ import { setIsMuted, setVolume, setCurrentlyAt, + jumpToPreviousSegment, + jumpToNextSegment, } from "../redux/videoSlice"; import { ThemedTooltip } from "./Tooltip"; import VideoPlayers, { VideoPlayerForwardRef } from "./VideoPlayers"; @@ -149,6 +151,8 @@ const Thumbnail: React.FC = () => { setIsMuted={setIsMuted} setVolume={setVolume} setIsPlayPreview={setIsPlayPreview} + jumpToPreviousSegment={jumpToPreviousSegment} + jumpToNextSegment={jumpToNextSegment} /> diff --git a/src/main/VideoControls.tsx b/src/main/VideoControls.tsx index 74088bb9f..9ce2ecd60 100644 --- a/src/main/VideoControls.tsx +++ b/src/main/VideoControls.tsx @@ -3,7 +3,7 @@ import React from "react"; import { css } from "@emotion/react"; import { FaToggleOn, FaToggleOff } from "react-icons/fa"; -import { LuPlay, LuPause, LuVolume2, LuVolumeX } from "react-icons/lu"; +import { LuPlay, LuPause, LuVolume2, LuVolumeX, LuSkipBack, LuSkipForward } from "react-icons/lu"; import { useAppDispatch, useAppSelector } from "../redux/store"; import { @@ -17,7 +17,7 @@ import { KEYMAP, rewriteKeys } from "../globalKeys"; import { useTranslation } from "react-i18next"; import { RootState } from "../redux/store"; -import { ActionCreatorWithPayload } from "@reduxjs/toolkit"; +import { ActionCreatorWithoutPayload, ActionCreatorWithPayload } from "@reduxjs/toolkit"; import { ThemedTooltip } from "./Tooltip"; import { Theme, useTheme } from "../themes"; @@ -38,6 +38,8 @@ const VideoControls: React.FC<{ setIsMuted: ActionCreatorWithPayload, setVolume: ActionCreatorWithPayload, setIsPlayPreview: ActionCreatorWithPayload, + jumpToPreviousSegment?: ActionCreatorWithoutPayload, + jumpToNextSegment?: ActionCreatorWithoutPayload, }> = ({ selectCurrentlyAt, selectIsPlaying, @@ -48,6 +50,8 @@ const VideoControls: React.FC<{ setIsMuted, setVolume, setIsPlayPreview, + jumpToPreviousSegment = undefined, + jumpToNextSegment = undefined, }) => { const theme = useTheme(); @@ -71,10 +75,20 @@ const VideoControls: React.FC<{ + {jumpToPreviousSegment && ( + + )} + {jumpToNextSegment && ( + + )} , +}> = ({ + jumpToPreviousSegment, +}) => { + + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const jumpToPrevious = () => { + dispatch(jumpToPreviousSegment()); + }; + + useHotkeys(KEYMAP.videoPlayer.previous.key, () => jumpToPrevious(), { preventDefault: true }); + + const previousIconStyle = css({ + fontSize: 24, + }); + + return ( + +
{ + if (event.key === "Enter") { + jumpToPrevious(); + } + }}> + +
+
+ ); +}; + +/** + * Jump to next segment + */ +const NextButton: React.FC<{ + jumpToNextSegment: ActionCreatorWithoutPayload, +}> = ({ + jumpToNextSegment, +}) => { + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const jumpToNext = () => { + dispatch(jumpToNextSegment()); + }; + + useHotkeys(KEYMAP.videoPlayer.next.key, () => jumpToNext(), { preventDefault: true }); + + const nextIconStyle = css({ + fontSize: 24, + }); + + return ( + +
{ + if (event.key === "Enter") { + jumpToNext(); + } + }}> + +
+
+ ); +}; + /** * Live update for the current time */ diff --git a/src/main/VideoPlayers.tsx b/src/main/VideoPlayers.tsx index 5af37414b..0eb23d6e8 100644 --- a/src/main/VideoPlayers.tsx +++ b/src/main/VideoPlayers.tsx @@ -18,6 +18,8 @@ import { selectAspectRatio, setClickTriggered, selectClickTriggered, + setJumpTriggered, + selectJumpTriggered, setCurrentlyAt, } from "../redux/videoSlice"; @@ -80,10 +82,12 @@ const VideoPlayers: React.FC<{ selectCurrentlyAtInSeconds={selectCurrentlyAtInSeconds} selectPreviewTriggered={selectPreviewTriggered} selectClickTriggered={selectClickTriggered} + selectJumpTriggered={selectJumpTriggered} selectAspectRatio={selectAspectRatio} setIsPlaying={setIsPlaying} setPreviewTriggered={setPreviewTriggered} setClickTriggered={setClickTriggered} + setJumpTriggered={setJumpTriggered} setCurrentlyAt={setCurrentlyAt} setAspectRatio={setAspectRatio} ref={el => { @@ -119,10 +123,12 @@ interface VideoPlayerProps { selectCurrentlyAtInSeconds: (state: RootState) => number, selectPreviewTriggered: (state: RootState) => boolean, selectClickTriggered: (state: RootState) => boolean, + selectJumpTriggered: (state: RootState) => boolean, selectAspectRatio: (state: RootState) => number, setIsPlaying: ActionCreatorWithPayload, setPreviewTriggered: ActionCreatorWithPayload, setClickTriggered: ActionCreatorWithPayload, + setJumpTriggered: ActionCreatorWithPayload, setCurrentlyAt: ActionCreatorWithPayload | AsyncThunk, setAspectRatio: ActionCreatorWithPayload<{ dataKey: number; } & { width: number, height: number; }, string>, } @@ -147,6 +153,7 @@ export const VideoPlayer = React.forwardRef { diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts index 444058957..910d3e990 100644 --- a/src/redux/videoSlice.ts +++ b/src/redux/videoSlice.ts @@ -12,6 +12,7 @@ export interface video { volume: number, // Video playback volume previewTriggered: boolean, // Basically acts as a callback for the video players. clickTriggered: boolean, // Another video player callback + jumpTriggered: boolean, // Another video player callback currentlyAt: number, // Position in the video in milliseconds segments: Segment[], tracks: Track[], @@ -54,6 +55,7 @@ export const initialState: video & httpRequestState = { selectedWorkflowId: "", previewTriggered: false, clickTriggered: false, + jumpTriggered: false, aspectRatios: [], hasChanges: false, waveformImages: [], @@ -136,12 +138,42 @@ const videoSlice = createSlice({ setClickTriggered: (state, action: PayloadAction) => { state.clickTriggered = action.payload; }, + setJumpTriggered: (state, action: PayloadAction) => { + state.jumpTriggered = action.payload; + }, setCurrentlyAt: (state, action: PayloadAction) => { updateCurrentlyAt(state, action.payload); }, setCurrentlyAtInSeconds: (state, action: PayloadAction) => { updateCurrentlyAt(state, roundToDecimalPlace(action.payload * 1000, 0)); }, + jumpToPreviousSegment: state => { + let previousSegmentIndex = state.activeSegmentIndex - 1; + + // Jump to start of active segment if current time is in interval [start + 3s, end) + if (state.currentlyAt >= state.segments[state.activeSegmentIndex].start + 3000) { + previousSegmentIndex = state.activeSegmentIndex; + } + + if (state.activeSegmentIndex == 0) { + // Jump to start of first segment + previousSegmentIndex = state.activeSegmentIndex; + } + + updateCurrentlyAt(state, state.segments[previousSegmentIndex].start); + state.jumpTriggered = true; + }, + jumpToNextSegment: state => { + let nextSegmentIndex = state.activeSegmentIndex + 1; + + if (state.activeSegmentIndex + 1 >= state.segments.length) { + // Jump to start of last segment + nextSegmentIndex = state.activeSegmentIndex; + } + + updateCurrentlyAt(state, state.segments[nextSegmentIndex].start); + state.jumpTriggered = true; + }, addSegment: (state, action: PayloadAction) => { state.segments.push(action.payload); }, @@ -276,6 +308,7 @@ const videoSlice = createSlice({ selectVolume: state => state.volume, selectPreviewTriggered: state => state.previewTriggered, selectClickTriggered: state => state.clickTriggered, + selectJumpTriggered: state => state.jumpTriggered, selectCurrentlyAt: state => state.currentlyAt, selectCurrentlyAtInSeconds: state => state.currentlyAt / 1000, selectSegments: state => state.segments, @@ -311,7 +344,7 @@ const videoSlice = createSlice({ * @param state */ const updateActiveSegment = (state: video) => { - state.activeSegmentIndex = state.segments.findIndex(element => + state.activeSegmentIndex = state.segments.findLastIndex(element => element.start <= state.currentlyAt && element.end >= state.currentlyAt); // If there is an error, assume the first (the starting) segment if (state.activeSegmentIndex < 0) { @@ -421,10 +454,34 @@ const setThumbnailHelper = (state: video, id: Track["id"], uri: Track["thumbnail } }; -export const { setTrackEnabled, setIsPlaying, setIsPlayPreview, setIsMuted, setVolume, setCurrentlyAt, - setCurrentlyAtInSeconds, addSegment, setAspectRatio, setHasChanges, setWaveformImages, setThumbnails, setThumbnail, - removeThumbnail, setLock, cut, markAsDeletedOrAlive, setSelectedWorkflowIndex, mergeLeft, mergeRight, mergeAll, - setPreviewTriggered, setClickTriggered } = videoSlice.actions; +export const { + setTrackEnabled, + setIsPlaying, + setIsPlayPreview, + setIsMuted, + setVolume, + setCurrentlyAt, + setCurrentlyAtInSeconds, + addSegment, + setAspectRatio, + setHasChanges, + setWaveformImages, + setThumbnails, + setThumbnail, + removeThumbnail, + setLock, + cut, + markAsDeletedOrAlive, + setSelectedWorkflowIndex, + mergeLeft, + mergeRight, + mergeAll, + setPreviewTriggered, + setClickTriggered, + setJumpTriggered, + jumpToPreviousSegment, + jumpToNextSegment, +} = videoSlice.actions; export const selectVideos = createSelector( [(state: { videoState: { tracks: video["tracks"]; }; }) => state.videoState.tracks], @@ -439,6 +496,7 @@ export const { selectVolume, selectPreviewTriggered, selectClickTriggered, + selectJumpTriggered, selectCurrentlyAt, selectCurrentlyAtInSeconds, selectSegments,