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
+
+ :
+ // 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, id: Track["id"], uri: Track["thumbnailUri"]) => {
+ const index = state.tracks.findIndex(t => t.id === id)
+ if (index >= 0) {
+ state.tracks[index].thumbnailUri = uri
+ }
+}
+
export const { setTrackEnabled, setIsPlaying, setIsPlayPreview, setCurrentlyAt, setCurrentlyAtInSeconds,
- addSegment, setAspectRatio, setHasChanges, setWaveformImages, cut, markAsDeletedOrAlive, setSelectedWorkflowIndex,
- mergeLeft, mergeRight, setPreviewTriggered, setClickTriggered } = videoSlice.actions
+ addSegment, setAspectRatio, setHasChanges, setWaveformImages, setThumbnails, setThumbnail, removeThumbnail,
+ cut, markAsDeletedOrAlive, setSelectedWorkflowIndex, mergeLeft, mergeRight, setPreviewTriggered,
+ setClickTriggered } = videoSlice.actions
// Export selectors
// Selectors mainly pertaining to the video state
@@ -335,6 +359,8 @@ export const hasChanges = (state: { videoState: { hasChanges: video["hasChanges"
state.videoState.hasChanges
export const selectWaveformImages = (state: { videoState: { waveformImages: video["waveformImages"]; }; }) =>
state.videoState.waveformImages
+export const selectOriginalThumbnails = (state: { videoState: { originalThumbnails: video["originalThumbnails"]; }; }) =>
+ state.videoState.originalThumbnails
// Selectors mainly pertaining to the information fetched from Opencast
export const selectVideoURL = (state: { videoState: { videoURLs: video["videoURLs"] } }) => state.videoState.videoURLs
diff --git a/src/types.ts b/src/types.ts
index b4e3b54d0..6cc4cf8ac 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -8,9 +8,16 @@ export interface Segment {
export interface Track {
id: string,
uri: string,
- flavor: any,
+ flavor: Flavor,
audio_stream: any,
video_stream: any,
+ thumbnailUri: string | undefined,
+ thumbnailPriority: number,
+}
+
+export interface Flavor {
+ type: string,
+ subtype: string,
}
export interface Workflow {