diff --git a/editor-settings.toml b/editor-settings.toml
index ab3a01da7..c124c77c5 100644
--- a/editor-settings.toml
+++ b/editor-settings.toml
@@ -79,6 +79,19 @@
# Default: true
#show = true
+# Ensure that at least one video stream remains selected
+# Typically, the track selection ensures that at least one video stream
+# remains selected. If you would like your users to be able to create selections
+# with only audio streams, set this to false.
+# Default: true
+#atLeastOneVideo = true
+
+# Disables track selection for events with more than two videos
+# If your Opencast can handle track selection for more than two videos, set this
+# to false.
+# Default: true
+#atMostTwoVideos = true
+
####
# Subtitles
##
diff --git a/public/editor-settings.toml b/public/editor-settings.toml
index 94ad8b6b9..32e761456 100644
--- a/public/editor-settings.toml
+++ b/public/editor-settings.toml
@@ -28,6 +28,8 @@ show = true
[trackSelection]
show = true
+atLeastOneVideo = true
+atMostTwoVideos = true
[subtitles]
show = true
diff --git a/src/config.ts b/src/config.ts
index 82019acd7..03e5343c2 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -55,6 +55,8 @@ interface iSettings {
},
trackSelection: {
show: boolean,
+ atLeastOneVideo: boolean,
+ atMostTwoVideos: boolean,
},
thumbnail: {
show: boolean,
@@ -93,6 +95,8 @@ const defaultSettings: iSettings = {
},
trackSelection: {
show: true,
+ atLeastOneVideo: true,
+ atMostTwoVideos: true,
},
thumbnail: {
show: false,
@@ -403,6 +407,8 @@ const SCHEMA = {
},
trackSelection: {
show: types.boolean,
+ atLeastOneVideo: types.boolean,
+ atMostTwoVideos: types.boolean,
},
subtitles: {
show: types.boolean,
diff --git a/src/cssStyles.tsx b/src/cssStyles.tsx
index 1392b59cf..2a7dd05d2 100644
--- a/src/cssStyles.tsx
+++ b/src/cssStyles.tsx
@@ -433,6 +433,11 @@ export const backgroundBoxStyle = (theme: Theme) => css(({
gap: "25px",
}));
+export const checkboxStyle = (theme: Theme) => css({
+ color: theme.text,
+ "&.Mui-disabled": { color: theme.disabled },
+});
+
export const undisplay = (maxWidth: number) => css({
[`@media (max-width: ${maxWidth}px)`]: {
display: "none",
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index accb088a3..8b66cb98b 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -240,13 +240,22 @@
"trackSelection": {
"title": "Select track(s) for processing",
- "trackInactive": "inactive",
- "deleteTrackText": "Delete Track",
- "restoreTrackText": "Restore Track",
- "cannotDeleteTrackText": "Cannot Delete Track",
- "deleteTrackTooltip": "Do not encode and publish this track.",
- "restoreTrackTooltip": "Encode and publish this track.",
- "cannotDeleteTrackTooltip": "Cannot remove this track from publication."
+ "help": "At least one track has to be selected.",
+ "helpAtLeastOneVideo": "At least one video track has to be selected.",
+ "atMostTwoVideos": "Track Selection is disabled for events with more than two videos",
+ "customizeLabel": "Customize track selection",
+ "videoTracksHeader": "Video tracks",
+ "audioTracksHeader": "Audio tracks",
+ "confirmText": "Confirm selection",
+ "confirmTooltip": "Confirm selected tracks",
+ "noAudioAvailable": "No audio available",
+ "selectionAlertInfoVideo_zero": "You have not selected any video track.",
+ "selectionAlertInfoVideo_one": "You have selected 1 video track.",
+ "selectionAlertInfoVideo_other": "You have selected {{count}} video tracks.",
+ "selectionAlertInfoAudio_zero": "You have not selected any audio track.",
+ "selectionAlertInfoAudio_one": "You have selected 1 audio track. It will be duplicated onto all videos.",
+ "selectionAlertInfoAudio_other": "You have selected {{count}} audio tracks.",
+ "selectionAlertError": "At least one video or audio track has to be selected."
},
"subtitles": {
diff --git a/src/img/placeholder-waveform.png b/src/img/placeholder-waveform.png
new file mode 100644
index 000000000..eac7b675e
Binary files /dev/null and b/src/img/placeholder-waveform.png differ
diff --git a/src/img/placeholder_waveform.png b/src/img/placeholder_waveform.png
deleted file mode 100644
index 3f6606cb4..000000000
Binary files a/src/img/placeholder_waveform.png and /dev/null differ
diff --git a/src/main/Save.tsx b/src/main/Save.tsx
index 49582b51e..a6144e9c0 100644
--- a/src/main/Save.tsx
+++ b/src/main/Save.tsx
@@ -10,6 +10,7 @@ import { LuCheckCircle, LuAlertCircle, LuChevronLeft, LuSave, LuCheck } from "re
import { useAppDispatch, useAppSelector } from "../redux/store";
import {
+ selectCustomizedTrackSelection,
selectHasChanges,
selectSegments,
selectTracks,
@@ -115,6 +116,7 @@ export const SaveButton: React.FC<{
const segments = useAppSelector(selectSegments);
const tracks = useAppSelector(selectTracks);
+ const customizedTrackSelection = useAppSelector(selectCustomizedTrackSelection);
const subtitles = useAppSelector(selectSubtitles);
const metadata = useAppSelector(selectCatalogs);
const workflowStatus = useAppSelector(selectStatus);
@@ -154,6 +156,7 @@ export const SaveButton: React.FC<{
dispatch(postVideoInformation({
segments: segments,
tracks: tracks,
+ customizedTrackSelection,
subtitles: prepareSubtitles(),
metadata: metadata,
}));
diff --git a/src/main/TrackSelection.tsx b/src/main/TrackSelection.tsx
index 7be0c65b2..dc162a02d 100644
--- a/src/main/TrackSelection.tsx
+++ b/src/main/TrackSelection.tsx
@@ -1,74 +1,198 @@
-import React from "react";
+import React, { useEffect } from "react";
import { css } from "@emotion/react";
+import { Alert, Checkbox, FormControlLabel } from "@mui/material";
-import { IconType } from "react-icons";
-import { LuTrash } from "react-icons/lu";
-import TrashRestore from "../img/trash-restore.svg?react";
import ReactPlayer from "react-player";
import { Track } from "../types";
-import { useAppDispatch, useAppSelector } from "../redux/store";
-import { selectVideos, setTrackEnabled } from "../redux/videoSlice";
import {
+ selectCustomizedTrackSelection,
+ selectVideos,
+ selectWaveformImages,
+ setAudioEnabled,
+ setCustomizedTrackSelection,
+ setVideoEnabled,
+} from "../redux/videoSlice";
+import {
+ BREAKPOINTS,
backgroundBoxStyle,
- basicButtonStyle,
- customIconStyle,
- deactivatedButtonStyle,
+ checkboxStyle,
titleStyle,
titleStyleBold,
} from "../cssStyles";
import { useTranslation } from "react-i18next";
-import { Theme, useTheme } from "../themes";
-import { ThemedTooltip } from "./Tooltip";
-import { ProtoButton } from "@opencast/appkit";
+import { useTheme } from "../themes";
+import { outOfBounds } from "../util/utilityFunctions";
+import { useAppDispatch, useAppSelector } from "../redux/store";
+import PlaceholderWaveform from "../img/placeholder-waveform.png";
+import { settings } from "../config";
/**
* Creates the track selection.
*/
const TrackSelection: React.FC = () => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const dispatch = useAppDispatch();
// Generate list of tracks
- const tracks: Track[] = useAppSelector(selectVideos);
- const enabledCount = tracks.filter(t => t.video_stream.enabled).length;
- const trackItems: JSX.Element[] = tracks.map((track: Track) =>
-
+ const tracks = useAppSelector(selectVideos);
+ let enabledCount = 0;
+ if (settings.trackSelection.atLeastOneVideo) {
+ // Only care about at least one video stream being enabled
+ enabledCount = tracks.reduce(
+ (memo: number, track: Track) => memo + !!track.video_stream.enabled,
+ 0
+ );
+ } else {
+ // Make sure that at least one track remains enabled
+ enabledCount = tracks.reduce(
+ (memo: number, track: Track) => memo + !!track.video_stream.enabled + !!track.audio_stream.enabled,
+ 0
+ );
+ }
+ const images = useAppSelector(selectWaveformImages);
+ const customizedTrackSelection = !!useAppSelector(selectCustomizedTrackSelection);
+
+ const videoTrackItems = tracks.map(
+ (track: Track) => (
+ )
);
- const trackSelectionStyle = css({
- display: "flex",
- width: "auto",
- height: "100%",
- flexDirection: "column",
- alignItems: "center",
- });
+ const audioTrackItems = tracks.map(
+ (track: Track, index: number) => (
+
+ )
+ );
- const trackAreaStyle = css({
- display: "flex",
- width: "100%",
- height: "100%",
- flexDirection: "row",
- justifyContent: "center",
- alignItems: "center",
- gap: "10px",
- });
+ const onChange = () => {
+ if (customizedTrackSelection) {
+ tracks.forEach(track => {
+ if (track.video_stream.available) {
+ dispatch(setVideoEnabled({
+ trackId: track.id,
+ enabled: true,
+ }));
+ }
+ if (track.audio_stream.available) {
+ dispatch(setAudioEnabled({
+ trackId: track.id,
+ enabled: true,
+ }));
+ }
+ });
+ }
+
+ dispatch(setCustomizedTrackSelection(!customizedTrackSelection));
+ };
+
+ const isDisabledBecauseMoreThanTwoVideos = () => {
+ if (settings.trackSelection.atMostTwoVideos && tracks.length > 2) {
+ return true;
+ }
+ return false;
+ };
+
+ const styles = {
+ trackSelection: css({
+ display: "flex",
+ height: "100%",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "2rem",
+ alignSelf: "center",
+ }),
+
+ trackArea: css({
+ display: "flex",
+ width: "100%",
+ flexDirection: "row",
+ flexWrap: "wrap",
+ justifyContent: "center",
+ alignItems: "stretch",
+ "& > *": {
+ flex: "1 1 0px",
+ },
+ gap: "10px",
+ }),
+
+ leftAlignedSection: css({
+ alignSelf: "start",
+ }),
+
+ selectionSection: css({
+ transition: "all 0.05s",
+ width: "100%",
+ ...(
+ customizedTrackSelection || isDisabledBecauseMoreThanTwoVideos()
+ ? {}
+ : {
+ opacity: "0.7",
+ pointerEvents: "none",
+ filter: "grayscale(80%) blur(1.5px) brightness(80%)",
+ }
+ ),
+ }),
+
+ trackSection: css({
+ "& h3": {
+ marginBlock: "1rem",
+ },
+ }),
+ };
+
+ if (isDisabledBecauseMoreThanTwoVideos()) {
+ return (
+
+
+
+
+ {t("trackSelection.atMostTwoVideos")}
+
+
+
+ );
+ }
return (
-
+
-
- {trackItems}
-
+
+
+
+ {t("trackSelection.videoTracksHeader")}
+ { videoTrackItems }
+
+
+ {t("trackSelection.audioTracksHeader")}
+ { audioTrackItems }
+
);
};
-
const Header: React.FC = () => {
-
const { t } = useTranslation();
const theme = useTheme();
-
const description: string = t("trackSelection.title");
return (
@@ -78,154 +202,244 @@ const Header: React.FC = () => {
);
};
-
-const TrackItem: React.FC<{ track: Track, enabledCount: number; }> = ({ track, enabledCount }) => {
-
+const TrackSelectionEnabler: React.FC<{
+ customizable: boolean,
+ onChange: () => void,
+}> = ({
+ customizable,
+ onChange,
+}) => {
const theme = useTheme();
-
const { t } = useTranslation();
- const dispatch = useAppDispatch();
- const header = track.flavor.type + " "
- + (track.video_stream.enabled ? ""
- : `(${t("trackSelection.trackInactive", "inactive")})`);
-
- const trackItemStyle = css({
- display: "flex",
- flexDirection: "column",
- alignItems: "left",
- });
+ const label = t("trackSelection.customizeLabel");
- const trackitemSubStyle = css({
- display: "flex",
- flexDirection: "row",
- gap: "20px",
+ return (
+
+ } label={label} />
+ );
+};
- justifyContent: "space-around",
- flexWrap: "wrap",
- });
+const VideoTrackItem: React.FC<{
+ track: Track,
+ enabledCount: number,
+ customizable: boolean,
+}> = ({
+ track,
+ enabledCount,
+ customizable,
+}) => {
+ const dispatch = useAppDispatch();
+ const imagesMaxWidth = 300;
+ const imagesMaxWidthMedium = 150;
+ const disabled = !customizable || (track.video_stream.enabled && enabledCount === 1);
const playerStyle = css({
aspectRatio: "16 / 9",
width: "100%",
- maxWidth: "457px",
- });
-
- const headerStyle = css({
- fontWeight: "bold",
- fontSize: "larger",
- color: `${theme.text}`,
- "&:first-letter": {
- textTransform: "capitalize",
+ opacity: track.video_stream.enabled ? "1" : "0.5",
+ maxWidth: `${imagesMaxWidthMedium}px`,
+ [`@media (min-width: ${BREAKPOINTS.medium}px)`]: {
+ "&": { maxWidth: `${imagesMaxWidth}px` },
},
});
- const buttonsStyle = css({
- // TODO: Avoid hard-coding max-width
- "@media (max-width: 1550px)": {
- width: "100%",
+ const playerRootStyle = {
+ filter: track.video_stream.enabled ? "none" : "grayscale(80%) blur(1.5px) brightness(80%)",
+ opacity: track.video_stream.enabled ? "1" : "0.5",
+ };
+
+ const videoEnabledChange = () => {
+ dispatch(setVideoEnabled({
+ trackId: track.id,
+ enabled: !track.video_stream.enabled,
+ }));
+ };
+
+ return (
+
+
+
+ );
+};
+
+const AudioTrackItem: React.FC<{
+ track: Track,
+ waveform: string | undefined
+ enabledCount: number,
+ customizable: boolean,
+}> = ({
+ track,
+ waveform,
+ enabledCount,
+ customizable,
+}) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const dispatch = useAppDispatch();
+ const imagesMaxWidth = 300;
+ const imagesMaxWidthMedium = 150;
+ const disabled = !customizable ||
+ (!settings.trackSelection.atLeastOneVideo && track.audio_stream.enabled && enabledCount === 1);
+
+ const imgStyle = css({
+ height: "54px", // Keep height consistent in case the image does not render
+ width: "100%",
+ filter: `${theme.invert_wave}`,
+ color: `${theme.inverted_text}`,
+ maxWidth: `${imagesMaxWidthMedium}px`,
+ [`@media (min-width: ${BREAKPOINTS.medium}px)`]: {
+ "&": { maxWidth: `${imagesMaxWidth}px` },
},
- display: "flex",
- flexDirection: "column",
});
- // What state is the track in and can it be deactivated?
- // We do not permit deactivating the last remaining track
- // 2 -> Track is enabled and can be deactivated
- // 1 -> Track is enabled but is the last and cannot be deactivated
- // 0 -> Track is disabled and can be restored
- const deleteStatus = track.video_stream.enabled ? (enabledCount > 1 ? 0 : 1) : 2;
- const deleteEnabled = deleteStatus !== 1;
- const deleteText = [
- t("trackSelection.deleteTrackText", "Delete Track"),
- t("trackSelection.cannotDeleteTrackText", "Cannot Delete Track"),
- t("trackSelection.restoreTrackText", "Restore Track"),
- ][deleteStatus];
- const deleteTooltip = [
- t("trackSelection.deleteTrackTooltip", "Do not encode and publish this track."),
- t("trackSelection.cannotDeleteTrackTooltip", "Cannot remove this track from publication."),
- t("trackSelection.restoreTrackTooltip", "Encode and publish this track."),
- ][deleteStatus];
- const deleteIcon = [LuTrash, LuTrash, TrashRestore][deleteStatus];
- const trackEnabledChange = () => {
- dispatch(setTrackEnabled({
- id: track.id,
- enabled: !track.video_stream.enabled,
+ const audioEnabledChange = () => {
+ dispatch(setAudioEnabled({
+ trackId: track.id,
+ enabled: !track.audio_stream.enabled,
}));
};
+ useEffect(() => {
+ if (!track.audio_stream.available) {
+ dispatch(setAudioEnabled({
+ trackId: track.id,
+ enabled: false,
+ }));
+ }
+ }, [track.audio_stream.available]);
+
return (
-
-
{header}
-
-
+ {track.audio_stream.available ?
+
-
-
+ :
+
{t("trackSelection.noAudioAvailable")}
+ }
+
+ );
+};
+
+const TrackItem: React.FC<{
+ header: string,
+ checked: boolean,
+ disabled: boolean,
+ onChange: () => void,
+ children: React.ReactNode,
+}> = ({
+ header,
+ checked,
+ disabled,
+ onChange,
+ children,
+}) => {
+ const theme = useTheme();
+
+ const styles = {
+ trackItem: css({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "left",
+ cursor: disabled ? "not-allowed" : "pointer",
+ }),
+
+ trackitemSub: css({
+ display: "flex",
+ flexDirection: "row",
+ gap: "20px",
+ justifyContent: "space-around",
+ flexWrap: "wrap",
+ flexGrow: "1",
+ alignItems: "center",
+ }),
+
+ images: css({
+ display: "flex",
+ flexDirection: "column",
+ gap: "20px",
+ }),
+
+ header: css({
+ fontWeight: "bold",
+ textTransform: "capitalize",
+ display: "flex",
+ alignItems: "center",
+ gap: "0.5em",
+ }),
+ };
+
+ return (
+
+
);
};
-interface selectButtonInterface {
- handler: () => void,
- text: string,
- Icon: IconType | React.FunctionComponent,
- tooltip: string,
- active: boolean,
+interface selectionAlertInterface {
+ tracks: Track[],
+ customizable: boolean,
}
-const SelectButton: React.FC = ({ handler, text, Icon, tooltip, active }) => {
-
+const SelectionAlert: React.FC = ({
+ tracks,
+ customizable,
+}) => {
+ const { t } = useTranslation();
const theme = useTheme();
+ const video = tracks.filter(t => t.video_stream.enabled).length;
+ const audio = tracks.filter(t => t.audio_stream.enabled).length;
- const buttonStyle = (theme: Theme) => [
- active ? basicButtonStyle(theme) : deactivatedButtonStyle,
- css({
- padding: "16px",
- maxHeight: "21px",
- boxShadow: "",
- background: `${theme.element_bg}`,
- textWrap: "nowrap",
- })];
-
- const clickHandler = () => {
- if (active) { handler(); }
- ref.current?.blur();
- };
-
- const keyHandler = (event: React.KeyboardEvent) => {
- if (active && (event.key === " " || event.key === "Enter")) {
- handler();
- }
- };
-
- const ref = React.useRef(null);
+ const lines = customizable ? [
+ t("trackSelection.selectionAlertInfoVideo", { count: video }),
+ t("trackSelection.selectionAlertInfoAudio", { count: audio }),
+ ] : [];
return (
-
-
-
- {text}
-
-
+
+
+ {settings.trackSelection.atLeastOneVideo ? t("trackSelection.helpAtLeastOneVideo") : t("trackSelection.help")}
+
+
+ {lines.map((line, index) => ({line}
))}
+
);
};
diff --git a/src/main/WorkflowConfiguration.tsx b/src/main/WorkflowConfiguration.tsx
index ae8ef7f63..dfcd1fb43 100644
--- a/src/main/WorkflowConfiguration.tsx
+++ b/src/main/WorkflowConfiguration.tsx
@@ -9,9 +9,7 @@ import {
import { LuChevronLeft, LuMoreHorizontal } from "react-icons/lu";
import { useAppSelector } from "../redux/store";
-
import { PageButton } from "./Finish";
-
import { useTranslation } from "react-i18next";
import { useTheme } from "../themes";
import { selectError, selectStatus } from "../redux/workflowPostSlice";
diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts
index abd040880..f5d666b4f 100644
--- a/src/redux/videoSlice.ts
+++ b/src/redux/videoSlice.ts
@@ -1,5 +1,5 @@
import { clamp } from "lodash";
-import { createSlice, nanoid, PayloadAction, createSelector } from "@reduxjs/toolkit";
+import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit";
import { client } from "../util/client";
import { Segment, httpRequestState, Track, Workflow, SubtitlesFromOpencast } from "../types";
@@ -18,6 +18,7 @@ export interface video {
currentlyAt: number, // Position in the video in milliseconds
segments: Segment[],
tracks: Track[],
+ customizedTrackSelection: boolean, // Did user select tracks for processing
subtitlesFromOpencast: SubtitlesFromOpencast[],
activeSegmentIndex: number, // Index of the segment that is currenlty hovered
selectedWorkflowId: string, // Id of the currently selected workflow
@@ -53,6 +54,7 @@ export const initialState: video & httpRequestState = {
currentlyAt: 0, // Position in the video in milliseconds
segments: [{ id: nanoid(), start: 0, end: 1, deleted: false }],
tracks: [],
+ customizedTrackSelection: false,
subtitlesFromOpencast: [],
activeSegmentIndex: 0,
selectedWorkflowId: "",
@@ -115,15 +117,22 @@ const videoSlice = createSlice({
name: "videoState",
initialState,
reducers: {
- setTrackEnabled: (state, action) => {
+ setVideoEnabled: (state, action: PayloadAction<{trackId: string, enabled: boolean}>) => {
for (const track of state.tracks) {
- if (track.id === action.payload.id) {
- track.audio_stream.enabled = action.payload.enabled;
+ if (track.id === action.payload.trackId) {
track.video_stream.enabled = action.payload.enabled;
}
}
state.hasChanges = true;
},
+ setAudioEnabled: (state, action: PayloadAction<{trackId: string, enabled: boolean}>) => {
+ for (const track of state.tracks) {
+ if (track.id === action.payload.trackId) {
+ track.audio_stream.enabled = action.payload.enabled;
+ }
+ }
+ state.hasChanges = true;
+ },
setIsPlaying: (state, action: PayloadAction