diff --git a/public/editor-settings.toml b/public/editor-settings.toml index 0bfcb3b79..da61c399d 100644 --- a/public/editor-settings.toml +++ b/public/editor-settings.toml @@ -96,7 +96,19 @@ password = "opencast" [thumbnail] # If the thumbnail editor appears in the main menu -# Warning: This interface is unfinished # Type: boolean # Default: false -#show = false +show = true + +# Whether to use "simple" or "professional" mode. +# Professional mode allows users to edit all thumbnails that fit the subflavor +# specified in the Opencast configuration file +# `etc/org.opencastproject.editor.EditorServiceImpl.cfg`. It is useful +# when working with multiple thumbnails. +# Simple mode only allows users to edit the "primary" thumbnail, as specified +# in the Opencast configuration file +# `etc/org.opencastproject.editor.EditorServiceImpl.cfg`. It is useful +# when there is only a single thumbnail to worry about and you want hide +# potential fallbacks from the user. If a primary thumbnail cannot be +# determined, this falls back to professional mode. +simpleMode = false diff --git a/src/config.ts b/src/config.ts index 5a09d350e..01806c67c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,7 @@ interface iSettings { }, thumbnail: { show: boolean, + simpleMode: boolean, } } @@ -70,6 +71,7 @@ var defaultSettings: iSettings = { }, thumbnail: { show: false, + simpleMode: false, } } var configFileSettings: iSettings @@ -311,6 +313,7 @@ const SCHEMA = { }, thumbnail: { show : types.boolean, + simpleMode: types.boolean, } } diff --git a/src/cssStyles.tsx b/src/cssStyles.tsx index c4b7e5979..4598fb73b 100644 --- a/src/cssStyles.tsx +++ b/src/cssStyles.tsx @@ -131,6 +131,28 @@ export const backOrContinueStyle = css(({ ...(flexGapReplacementStyle(20, false)), })) +/** + * CSS for a title + */ +export const titleStyle = css(({ + display: 'inline-block', + padding: '15px', + overflow: 'hidden', + whiteSpace: "nowrap", + textOverflow: 'ellipsis', + maxWidth: '500px', +})) + +/** + * Addendum for the titleStyle + * Used for page titles + */ +export const titleStyleBold = css({ + fontWeight: 'bold', + fontSize: '24px', + verticalAlign: '-2.5px', +}) + /** * CSS for ariaLive regions that should not be visible */ diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index db423cfd7..4980fdec0 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -165,6 +165,35 @@ } }, + "thumbnail": { + "title": "Thumbnail Editor", + "noThumbnailAvailable": "No Thumbnail set", + "previewImageAlt": "Thumbnail for", + "buttonGenerate": "Generate", + "buttonGenerate-tooltip": "Create a new thumbnail from the current scrubber position", + "buttonGenerate-tooltip-aria": "Create a new thumbnail from the current scrubber position", + "buttonUpload": "Upload", + "buttonUpload-tooltip": "Upload an image", + "buttonUpload-tooltip-aria": "Upload an image", + "buttonUseForOtherThumbnails": "Use for other thumbnails", + "buttonUseForOtherThumbnails-tooltip": "Use the thumbnail for all other videos", + "buttonUseForOtherThumbnails-tooltip-aria": "Use the thumbnail for all other videos", + "buttonDiscard": "Discard", + "buttonDiscard-tooltip": "Undo all changes made to this thumbnail", + "buttonDiscard-tooltip-aria": "Undo all changes made to this thumbnail", + "buttonGenerateAll": "Generate All", + "buttonGenerateAll-tooltip": "Create new thumbnails from the current scrubber position", + "buttonGenerateAll-tooltip-aria": "Create new thumbnails from the current scrubber position", + "explanation": "Here you can set the thumbnail for each video", + "primary": "Primary", + "secondary": "Secondary" + }, + + "thumbnailSimple": { + "rowTitle": "Change thumbnail here", + "from": "from" + }, + "error": { "generic-message": "A critical error has occurred!", "details": "Details: ", diff --git a/src/main/MainContent.tsx b/src/main/MainContent.tsx index 3bf54cd70..99aebd428 100644 --- a/src/main/MainContent.tsx +++ b/src/main/MainContent.tsx @@ -24,6 +24,7 @@ import { hasChanges as videoHasChanges } from "../redux/videoSlice"; import { hasChanges as metadataHasChanges} from "../redux/metadataSlice"; import { selectTheme } from "../redux/themeSlice"; import ThemeSwitcher from "./ThemeSwitcher"; +import Thumbnail from "./Thumbnail"; /** * A container for the main functionality @@ -82,6 +83,16 @@ const MainContent: React.FC<{}> = () => { background: `${theme.background}`, }) + const thumbnailSelectStyle = css({ + ...displayState(MainMenuStateNames.thumbnail), + flexDirection: 'column' as const, + alignContent: 'space-around', + ...(flexGapReplacementStyle(20, false)), + paddingRight: '20px', + paddingLeft: '161px', + background: `${theme.background}`, + }) + const finishStyle = css({ ...displayState(MainMenuStateNames.finish), flexDirection: 'column' as const, @@ -123,6 +134,9 @@ const MainContent: React.FC<{}> = () => {
+
+ +
diff --git a/src/main/Thumbnail.tsx b/src/main/Thumbnail.tsx new file mode 100644 index 000000000..de42dfc46 --- /dev/null +++ b/src/main/Thumbnail.tsx @@ -0,0 +1,587 @@ +import { css } from "@emotion/react"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { faCamera, faCopy, faInfoCircle, faTimesCircle, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { t } from "i18next"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { settings } from "../config"; +import { basicButtonStyle, deactivatedButtonStyle, flexGapReplacementStyle, titleStyle, titleStyleBold } from "../cssStyles"; +import { selectTheme, Theme } from "../redux/themeSlice"; +import { selectOriginalThumbnails, selectTracks, setHasChanges, setThumbnail, setThumbnails } from "../redux/videoSlice"; +import { Track } from "../types"; +import Timeline from "./Timeline"; +import { VideoControls, VideoPlayers } from "./Video"; + + +/** + * User interface for handling thumbnails + */ +const Thumbnail : React.FC<{}> = () => { + + const { t } = useTranslation() + const dispatch = useDispatch() + + const originalThumbnails = useSelector(selectOriginalThumbnails) + + // Generate Refs + const generateRefs = React.useRef([]); + // Upload Refs + const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]); + + // Generate image and save in redux + // *track: Generate to + // *index: Generate from + const generate = (track: Track, index: number) => { + const uri = generateRefs.current[index].captureVideo() + dispatch(setThumbnail({id: track.id, uri: uri})) + dispatch(setHasChanges(true)) + } + + // Trigger file handler for upload input element + const upload = (index: number) => { + // open file input box on click of other element + const ref = inputRefs.current[index] + if (ref !== null) { + ref.click(); + } + }; + + // Save uploaded file in redux + const uploadCallback = (event: React.ChangeEvent, track: Track) => { + const fileObj = event.target.files && event.target.files[0]; + if (!fileObj) { + return; + } + + // Check if image + if (fileObj.type.split('/')[0] !== 'image') { + return + } + + var reader = new FileReader(); + reader.onload = function(e) { + // the result image data + if (e.target && e.target.result) { + const uri = e.target.result.toString(); + dispatch(setThumbnail({id: track.id, uri: uri})) + dispatch(setHasChanges(true)) + } + } + reader.readAsDataURL(fileObj); + }; + + const discardThumbnail = (id: string) => { + dispatch(setThumbnail({ id: id, uri: originalThumbnails.find((e: any) => e.id === id)?.uri })) + } + + const thumbnailStyle = css({ + display: 'flex', + width: '100%', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + }) + + return ( +
+
{t('thumbnail.title')}
+ + + + +
+ ); +} + +/** + * A table for displaying thumbnails and associated actions + */ +const ThumbnailTable : React.FC<{ + inputRefs: any, + generate: any, + upload: any, + uploadCallback: any, + discard: any, +}> = ({inputRefs, generate, upload, uploadCallback, discard}) => { + + const tracks = useSelector(selectTracks) + + const thumbnailTableStyle = css({ + display: 'flex', + flexDirection: 'row' as const, + justifyContent: 'space-around', + flexWrap: 'wrap', + ...(flexGapReplacementStyle(10, false)), + }) + + const renderSingleOrMultiple = () => { + const primaryTrack = tracks.find((e) => e.thumbnailPriority === 0) + + if (settings.thumbnail.simpleMode && primaryTrack !== undefined) { + return (<> + + ) + } else { + return ( <> + + {tracks.map( (track: Track, index: number) => ( + + ))} + ) + } + } + + return( +
+ {renderSingleOrMultiple()} +
+ ) +} + +/** + * A table entry for a single video+thumbnail pair + */ +const ThumbnailTableRow: React.FC<{ + track: Track, + index: number, + inputRefs: any, + generate: any, + upload: any, + uploadCallback: any, + discard: any, +}> = ({track, index, inputRefs, generate, upload, uploadCallback, discard}) => { + + const { t } = useTranslation() + + const renderPriority = (thumbnailPriority: number) => { + if (isNaN(thumbnailPriority)) { + return "" + } else if (thumbnailPriority === 0) { + return " - " + t('thumbnail.primary') + } else if (thumbnailPriority === 1) { + return " - " + t('thumbnail.secondary') + } else if (thumbnailPriority < 0) { + return "" + } else { + return " - " + thumbnailPriority + } + } + + return ( +
+
+ {track.flavor.type + renderPriority(track.thumbnailPriority)} +
+
+
+ + +
+
+ ) +} + +/** + * Displays thumbnail associated with the given track + * or a placeholder + */ +const ThumbnailDisplayer : React.FC<{track: Track}> = ({track}) => { + + const { t } = useTranslation() + + const generalStyle = css({ + height: '280px', + }) + + const imageStyle = css({ + maxHeight: '100%', + }) + + const placeholderStyle = css({ + backgroundColor: 'grey', + // For whatever reason, setting the width relative to height is way to difficult, + // so we hardcode the box size here + width: '497px', + // Support for aspectRatio is still spotty and implementations across browsers + // differ too much + // aspectRatio: '16/9', + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }) + + return ( + <> + {(track.thumbnailUri !== null && track.thumbnailUri !== undefined) ? + // Thumbnail image + {t('thumbnail.previewImageAlt') + : + // Placeholder +
+ {t('thumbnail.noThumbnailAvailable')} +
+ } + + ) +} + +/** + * Buttons and actions related to thumbnails for a given track + */ +const ThumbnailButtons : React.FC<{ + track: Track, + index: number, + inputRefs: any, + generate: any, + upload: any, + uploadCallback: any, + discard: any, +}> = ({track, index, inputRefs, generate, upload, uploadCallback, discard}) => { + + const { t } = useTranslation() + const dispatch = useDispatch() + + const tracks = useSelector(selectTracks) + + // Set the given thumbnail for all tracks + const setForOtherThumbnails = (uri: string | undefined) => { + if (uri === undefined) { + return + } + const thumbnails = [] + for (const track of tracks) { + thumbnails.push({id: track.id, uri: uri}) + } + dispatch(setThumbnails(thumbnails)) + dispatch(setHasChanges(true)) + } + + return ( +
+ { generate(track, index) }} + text={t('thumbnail.buttonGenerate')} + tooltipText={t('thumbnail.buttonGenerate-tooltip')} + ariaLabel={t('thumbnail.buttonGenerate-tooltip-aria')} + icon={faCamera} + active={true} + /> + { upload(index) }} + text={t('thumbnail.buttonUpload')} + tooltipText={t('thumbnail.buttonUpload-tooltip')} + ariaLabel={t('thumbnail.buttonUpload-tooltip-aria')} + icon={faUpload} + active={true} + /> + {/* Hidden input field for upload */} + { + inputRefs.current[index] = el; + }} + type="file" + accept="image/*" + onChange={(event) => uploadCallback(event, track)} + aria-hidden="true" + /> + { setForOtherThumbnails(track.thumbnailUri) }} + text={t('thumbnail.buttonUseForOtherThumbnails')} + tooltipText={t('thumbnail.buttonUseForOtherThumbnails-tooltip')} + ariaLabel={t('thumbnail.buttonUseForOtherThumbnails-tooltip-aria')} + icon={faCopy} + active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true: false)} + /> + { discard(track.id) }} + text={t('thumbnail.buttonDiscard')} + tooltipText={t('thumbnail.buttonDiscard-tooltip')} + ariaLabel={t('thumbnail.buttonDiscard-tooltip-aria')} + icon={faTimesCircle} + active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true: false)} + /> +
+ ) +} + +const ThumbnailButton : React.FC<{ + handler: any, + text: string + tooltipText: string, + ariaLabel: string, + icon: IconProp, + active: boolean, +}> = ({handler, text, tooltipText, ariaLabel, icon, active}) => { + const theme = useSelector(selectTheme); + const ref = React.useRef(null) + + const clickHandler = () => { + active && handler(); + ref.current?.blur(); + }; + const keyHandler = (event: React.KeyboardEvent) => { + if (active && (event.key === " " || event.key === "Enter")) { + handler(); + } + }; + + return ( +
+ + {text} +
+ ) +} + +/** + * Extra header/footer row + * For e.g. buttons that affect all rows in the table + */ +const AffectAllRow : React.FC<{ + tracks: Track[] + generate: any +}> = ({generate, tracks}) => { + + const { t } = useTranslation() + const theme = useSelector(selectTheme); + + const generateAll = () => { + for (let i = 0; i < tracks.length; i++) { + generate(tracks[i], i) + } + } + + const rowStyle = css({ + display: 'flex', + flexDirection: 'row', + width: '100%', + height: '50px', + padding: '20px', + gap: '20px', + justifyContent: 'center', + alignItems: 'center', + borderTop: `${theme.menuBorder}`, + }) + + const buttonStyle = css({ + height: '100%', + minWidth: '200px', + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + }) + + return ( +
+ + {t('thumbnail.explanation')} +
{ + generateAll() + }} + onKeyDown={(event: React.KeyboardEvent) => { if (event.key === " " || event.key === "Enter") { + generateAll() + }}} + > + + {t('thumbnail.buttonGenerateAll')} +
+
+ ) +} + +/** + * Components for simple mode + */ + +/** + * Main simple mode component. A single table row displaying the interface for + * the primary thumbnail. + */ +const ThumbnailTableSingleRow: React.FC<{ + track: Track, + index: number, + inputRefs: any, + generate: any, + upload: any, + uploadCallback: any, + discard: any, +}> = ({track, index, inputRefs, generate, upload, uploadCallback, discard}) => { + + return ( +
+
+ {t("thumbnailSimple.rowTitle")} +
+
+
+ + +
+
+ ) +} + +/** + * Buttons for simple mode. Shows a generate button for each video + */ +const ThumbnailButtonsSimple : React.FC<{ + track: Track, + index: number, + inputRefs: any, + generate: any, + upload: any, + uploadCallback: any, + discard: any, +}> = ({track, index, generate, inputRefs, upload, uploadCallback, discard}) => { + + const { t } = useTranslation() + const tracks = useSelector(selectTracks) + + return ( +
+ {tracks.map( (generateTrack: Track, generateIndex: number) => ( + { generate(track, generateIndex) }} + text={t('thumbnail.buttonGenerate') + " " + t("thumbnailSimple.from") + " " + generateTrack.flavor.type} + tooltipText={t('thumbnail.buttonGenerate-tooltip')} + ariaLabel={t('thumbnail.buttonGenerate-tooltip-aria')} + icon={faCamera} + active={true} + key={generateIndex} + /> + ))} + { upload(index) }} + text={t('thumbnail.buttonUpload')} + tooltipText={t('thumbnail.buttonUpload-tooltip')} + ariaLabel={t('thumbnail.buttonUpload-tooltip-aria')} + icon={faUpload} + active={true} + /> + {/* Hidden input field for upload */} + { + inputRefs.current[index] = el; + }} + type="file" + accept="image/*" + onChange={(event) => uploadCallback(event, track)} + aria-hidden="true" + /> + { discard(track.id) }} + text={t('thumbnail.buttonDiscard')} + tooltipText={t('thumbnail.buttonDiscard-tooltip')} + ariaLabel={t('thumbnail.buttonDiscard-tooltip-aria')} + icon={faTimesCircle} + active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true: false)} + /> +
+ ) +} + +/** + * CSS shared between multi and simple display mode + */ +const thumbnailTableRowStyle = css({ + display: 'flex', + flexDirection: 'column', + padding: '6px 12px', +}) + +const thumbnailTableRowTitleStyle = css({ + textAlign: 'left', + textTransform: 'capitalize', + fontSize: 'larger', + fontWeight: 'bold', +}) + +const thumbnailTableRowRowStyle = css({ + display: 'flex', + flexDirection: 'row', + ...(flexGapReplacementStyle(20, true)), + + justifyContent: 'space-around', + flexWrap: 'wrap', +}) + +const thumbnailButtonsStyle = css({ + // TODO: Avoid hard-coding max-width + "@media (max-width: 1000px)": { + flexDirection: 'row', + width: '100%', + }, + display: 'flex', + flexDirection: 'column', + ...(flexGapReplacementStyle(20, true)), +}) + +const thumbnailButtonStyle = (active: boolean, theme: Theme) => [ + active ? basicButtonStyle : deactivatedButtonStyle, + { + width: '100%', + height: '100%', + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + justifySelf: 'center', + alignSelf: 'center', + padding: '0px 2px' + } +]; + + +export default Thumbnail; diff --git a/src/main/Timeline.tsx b/src/main/Timeline.tsx index 214f66bcf..ec54f4eb9 100644 --- a/src/main/Timeline.tsx +++ b/src/main/Timeline.tsx @@ -32,7 +32,7 @@ import { selectTheme } from '../redux/themeSlice'; * Its width corresponds to the duration of the video * TODO: Figure out why ResizeObserver does not update anymore if we stop passing the width to the SegmentsList */ -const Timeline: React.FC<{}> = () => { +const Timeline: React.FC<{timelineHeight?: number}> = ({timelineHeight = 250}) => { // Init redux variables const dispatch = useDispatch(); @@ -42,7 +42,7 @@ const Timeline: React.FC<{}> = () => { const timelineStyle = css({ position: 'relative', // Need to set position for Draggable bounds to work - height: '250px', + height: timelineHeight + 'px', width: '100%', }); @@ -56,10 +56,10 @@ const Timeline: React.FC<{}> = () => { return (
setCurrentlyAtToClick(e)}> - -
- - + +
+ +
); @@ -69,7 +69,7 @@ const Timeline: React.FC<{}> = () => { * Displays and defines the current position in the video * @param param0 */ -const Scrubber: React.FC<{timelineWidth: number}> = ({timelineWidth}) => { +const Scrubber: React.FC<{timelineWidth: number, timelineHeight: number}> = ({timelineWidth, timelineHeight}) => { const { t } = useTranslation(); @@ -156,7 +156,7 @@ const Scrubber: React.FC<{timelineWidth: number}> = ({timelineWidth}) => { const scrubberStyle = css({ backgroundColor: `${theme.text}`, - height: '240px', + height: timelineHeight - 10 + 'px', width: '1px', position: 'absolute' as 'absolute', zIndex: 2, @@ -244,7 +244,7 @@ const Scrubber: React.FC<{timelineWidth: number}> = ({timelineWidth}) => { /** * Container responsible for rendering the segments that are created when cutting */ -const SegmentsList: React.FC<{timelineWidth: number}> = ({timelineWidth}) => { +const SegmentsList: React.FC<{timelineWidth: number, timelineHeight: number}> = ({timelineWidth, timelineHeight}) => { const { t } = useTranslation(); @@ -298,7 +298,7 @@ const SegmentsList: React.FC<{timelineWidth: number}> = ({timelineWidth}) => { borderWidth: '1px', boxSizing: 'border-box', width: ((segment.end - segment.start) / duration) * 100 + '%', - height: '230px', + height: timelineHeight - 20 + 'px', zIndex: 1, }}>
@@ -322,7 +322,7 @@ const SegmentsList: React.FC<{timelineWidth: number}> = ({timelineWidth}) => { /** * Generates waveform images and displays them */ -const Waveforms: React.FC<{}> = () => { +const Waveforms: React.FC<{timelineHeight: number}> = ({timelineHeight}) => { const { t } = useTranslation(); @@ -342,7 +342,7 @@ const Waveforms: React.FC<{}> = () => { justifyContent: 'center', ...(images.length <= 0) && {alignItems: 'center'}, // Only center during loading width: '100%', - height: '230px', + height: timelineHeight - 20 + 'px', paddingTop: '10px', filter: `${theme.invert_wave}`, color: `${theme.inverted_text}`, diff --git a/src/main/Video.tsx b/src/main/Video.tsx index c26def591..339405fd8 100644 --- a/src/main/Video.tsx +++ b/src/main/Video.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useImperativeHandle } from "react"; import { css } from '@emotion/react' @@ -17,7 +17,7 @@ import { import ReactPlayer, { Config } from 'react-player' import { roundToDecimalPlace, convertMsToReadableString } from '../util/utilityFunctions' -import { basicButtonStyle, flexGapReplacementStyle } from "../cssStyles"; +import { basicButtonStyle, flexGapReplacementStyle, titleStyle, titleStyleBold } from "../cssStyles"; import { GlobalHotKeys } from 'react-hotkeys'; import { selectMainMenuState } from "../redux/mainMenuSlice"; @@ -35,14 +35,12 @@ import { selectTheme } from "../redux/themeSlice"; * Container for the videos and their controls * TODO: Move fetching to a more central part of the app */ -const Video: React.FC<{}> = () => { +export const Video: React.FC<{}> = () => { const { t } = useTranslation(); // Init redux variables const dispatch = useDispatch() - const videoURLs = useSelector(selectVideoURL) - const videoCount = useSelector(selectVideoCount) const videoURLStatus = useSelector((state: { videoState: { status: httpRequestState["status"] } }) => state.videoState.status); const error = useSelector((state: { videoState: { error: httpRequestState["error"] } }) => state.videoState.error) const theme = useSelector(selectTheme); @@ -71,13 +69,6 @@ const Video: React.FC<{}> = () => { // content =
{error}
// } - // Initialize video players - const videoPlayers: JSX.Element[] = []; - for (let i = 0; i < videoCount; i++) { - // videoPlayers.push(); - videoPlayers.push(); - } - // Style const videoAreaStyle = css({ display: 'flex', @@ -89,31 +80,60 @@ const Video: React.FC<{}> = () => { borderBottom: `${theme.menuBorder}`, }); + return ( +
+ + + +
+ ); +}; + +export const VideoPlayers: React.FC<{refs: any, widthInPercent?: number}> = ({refs, widthInPercent=100}) => { + + const videoURLs = useSelector(selectVideoURL) + const videoCount = useSelector(selectVideoCount) + const videoPlayerAreaStyle = css({ display: 'flex', flexDirection: 'row' as const, justifyContent: 'center', alignItems: 'center', - width: '100%', + width: widthInPercent + '%', }); + // Initialize video players + const videoPlayers: JSX.Element[] = []; + for (let i = 0; i < videoCount; i++) { + videoPlayers.push( + { + if (refs === undefined) return + (refs.current[i] = el) + }} + /> + ); + } + return ( -
- -
- {videoPlayers} -
- +
+ {videoPlayers}
); -}; +} /** * A single video player * @param {string} url - URL to load video from * @param {boolean} isPrimary - If the player is the main control */ -const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> = ({dataKey, url, isPrimary}) => { +export const VideoPlayer = React.forwardRef( + (props: {dataKey: number, url: string, isPrimary: boolean}, forwardRefThing) => { + const {dataKey, url, isPrimary } = props const { t } = useTranslation(); @@ -131,6 +151,7 @@ const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> const ref = useRef(null); const [ready, setReady] = useState(false); const [errorState, setError] = useState(false); + const [isAspectRatioUpdated, setIsAspectRatioUpdated] = useState(false); // Callback for when the video is playing const onProgressCallback = (state: { played: number, playedSeconds: number, loaded: number, loadedSeconds: number }) => { @@ -154,6 +175,7 @@ const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> h = (ref.current.getInternalPlayer() as HTMLVideoElement).videoHeight } dispatch(setAspectRatio({dataKey, width: w, height: h})) + setIsAspectRatioUpdated(true) } } @@ -161,8 +183,8 @@ const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> const onReadyCallback = () => { setReady(true); - // Update the store with video dimensions for rendering purposes - updateAspectRatio(); + // // Update the store with video dimensions for rendering purposes + // updateAspectRatio(); } const onEndedCallback = () => { @@ -185,6 +207,10 @@ const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> ref.current.seekTo(currentlyAt, "seconds") dispatch(setClickTriggered(false)) } + if (!isAspectRatioUpdated && ref.current && ready) { + // Update the store with video dimensions for rendering purposes + updateAspectRatio(); + } }) const onErrorCallback = (e: any) => { @@ -193,9 +219,29 @@ const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> // Skip player when navigating page with keyboard const playerConfig: Config = { - file: { attributes: { tabIndex: '-1' }} + file: { attributes: { + tabIndex: '-1', // don't tab navigate onto the video + crossOrigin: "anonymous" // allow thumbnail generation + }} } + // External functions + useImperativeHandle(forwardRefThing, () => ({ + // Renders the current frame in the video element to a canvas + // Returns the data url + captureVideo() { + const video = ref.current?.getInternalPlayer() as HTMLVideoElement + var canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + var canvasContext = canvas.getContext("2d"); + if (canvasContext !== null) { + canvasContext.drawImage(video, 0, 0); + return canvas.toDataURL('image/png') + } + } + })); + const errorBoxStyle = css({ ...(!errorState) && {display: "none"}, borderColor: `${theme.error}`, @@ -261,13 +307,13 @@ const VideoPlayer: React.FC<{dataKey: number, url: string, isPrimary: boolean}> // //
// ); -}; +}); /** * Contains controls for manipulating multiple video players at once * Flexbox magic keeps the play button at the center */ -const VideoControls: React.FC<{}> = () => { +export const VideoControls: React.FC<{}> = () => { const { t } = useTranslation(); @@ -449,21 +495,6 @@ const VideoHeader: React.FC<{}> = () => { const metadataTitle = useSelector(selectTitleFromEpisodeDc) const presenters = useSelector(selectPresenters) - const titleStyle = css({ - display: 'inline-block', - padding: '15px', - overflow: 'hidden', - whiteSpace: "nowrap", - textOverflow: 'ellipsis', - maxWidth: '500px', - }) - - const titleStyleBold = css({ - fontWeight: 'bold', - fontSize: '24px', - verticalAlign: '-2.5px', - }) - let presenter_header; if (presenters && presenters.length) { presenter_header =
by {presenters.join(", ")}
diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts index 6eace2860..e9bd29f5d 100644 --- a/src/redux/videoSlice.ts +++ b/src/redux/videoSlice.ts @@ -19,6 +19,7 @@ export interface video { aspectRatios: {width: number, height: number}[], // Aspect ratios of every video hasChanges: boolean // Did user make changes in cutting view since last save waveformImages: string[] + originalThumbnails: {id: Track["id"], uri: Track["thumbnailUri"]}[] videoURLs: string[], // Links to each video videoCount: number, // Total number of videos @@ -41,6 +42,7 @@ export const initialState: video & httpRequestState = { aspectRatios: [], hasChanges: false, waveformImages: [], + originalThumbnails: [], videoURLs: [], videoCount: 0, @@ -126,6 +128,18 @@ const videoSlice = createSlice({ setWaveformImages: (state, action: PayloadAction) => { state.waveformImages = action.payload }, + setThumbnail: (state, action: PayloadAction<{id: Track["id"], uri: Track["thumbnailUri"]}>) => { + setThumbnailHelper(state, action.payload.id, action.payload.uri) + }, + setThumbnails: (state, action: PayloadAction<{id: Track["id"], uri: Track["thumbnailUri"]}[]>) => { + for (const element of action.payload) { + setThumbnailHelper(state, element.id, element.uri) + } + }, + removeThumbnail: (state, action: PayloadAction) => { + const index = state.tracks.findIndex(t => t.id === action.payload) + state.tracks[index].thumbnailUri = undefined + }, cut: (state) => { // If we're exactly between two segments, we can't split the current segment if (state.segments[state.activeSegmentIndex].start === state.currentlyAt || @@ -194,18 +208,20 @@ const videoSlice = createSlice({ state.errorReason = 'workflowActive' state.error = "An Opencast workflow is currently running, please wait until it is finished." } + state.tracks = action.payload.tracks.sort((a: { thumbnailPriority: number; },b: { thumbnailPriority: number; }) => a.thumbnailPriority - b.thumbnailPriority) + const videos = state.tracks.filter((track: Track) => track.video_stream.available === true) // eslint-disable-next-line no-sequences - state.videoURLs = action.payload.tracks.reduce((a: string[], o: { uri: string }) => (a.push(o.uri), a), []) + state.videoURLs = videos.reduce((a: string[], o: { uri: string }) => (a.push(o.uri), a), []) state.videoCount = state.videoURLs.length state.duration = action.payload.duration state.title = action.payload.title state.presenters = [] state.segments = parseSegments(action.payload.segments, action.payload.duration) - state.tracks = action.payload.tracks state.workflows = action.payload.workflows.sort((n1: { displayOrder: number; },n2: { displayOrder: number; }) => { return n1.displayOrder - n2.displayOrder; }); state.waveformImages = action.payload.waveformURIs ? action.payload.waveformURIs : state.waveformImages + state.originalThumbnails = state.tracks.map((track: Track) => { return {id: track.id, uri: track.thumbnailUri} }) state.aspectRatios = new Array(state.videoCount) }) @@ -303,9 +319,17 @@ const calculateTotalAspectRatio = (aspectRatios: video["aspectRatios"]) => { return Math.min((minHeight / minWidth) * 100, (9/32) * 100) } +const setThumbnailHelper = (state: WritableDraft