diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 88087ea87..88b29d654 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -201,10 +201,10 @@ "title": "Thumbnail Editor", "noThumbnailAvailable": "No Thumbnail available", "previewImageAlt": "Thumbnail for track", - "buttonGenerate": "Generate", + "buttonGenerate": "Generate from frame", "buttonGenerate-tooltip": "Generate a new thumbnail from the current timeline marker position", "buttonGenerate-tooltip-aria": "Generate a new thumbnail from the current timeline marker position", - "buttonUpload": "Upload", + "buttonUpload": "Upload image", "buttonUpload-tooltip": "Upload an image", "buttonUpload-tooltip-aria": "Upload an image", "buttonUseForOtherThumbnails": "Use for all tracks", @@ -218,7 +218,9 @@ "buttonGenerateAll-tooltip-aria": "Generate new thumbnails for all tracks from the current timeline marker position", "explanation": "Upload or generate a thumbnail for each track.", "primary": "Primary", - "secondary": "Secondary" + "secondary": "Secondary", + "backButton": "Back", + "backButton-tooltip": "Return to thumbnail selection" }, "thumbnailSimple": { diff --git a/src/main/MainMenu.tsx b/src/main/MainMenu.tsx index 2eeb0a6dc..5a5230698 100644 --- a/src/main/MainMenu.tsx +++ b/src/main/MainMenu.tsx @@ -19,6 +19,7 @@ import { setIsPlaying } from "../redux/videoSlice"; import { useTranslation } from "react-i18next"; import { resetPostRequestState } from "../redux/workflowPostSlice"; import { setIsDisplayEditView } from "../redux/subtitleSlice"; +import { setIsDisplayEditView as setIsDisplayEditViewThumbnail } from "../redux/thumbnailSlice"; import { useTheme } from "../themes"; import { ProtoButton } from "@opencast/appkit"; @@ -133,6 +134,9 @@ export const MainMenuButton: React.FC = ({ if (stateName === MainMenuStateNames.subtitles) { dispatch(setIsDisplayEditView(false)); } + if (stateName === MainMenuStateNames.thumbnail) { + dispatch(setIsDisplayEditViewThumbnail(false)); + } // Halt ongoing events dispatch(setIsPlaying(false)); // Reset states diff --git a/src/main/Thumbnail.tsx b/src/main/Thumbnail.tsx index c6ce1706d..8a2545e33 100644 --- a/src/main/Thumbnail.tsx +++ b/src/main/Thumbnail.tsx @@ -1,673 +1,31 @@ -import { css } from "@emotion/react"; -import { IconType } from "react-icons"; -import { LuCamera, LuCopy, LuCircleX, LuUpload } from "react-icons/lu"; import React from "react"; -import { useTranslation } from "react-i18next"; -import { useAppDispatch, useAppSelector } from "../redux/store"; +import { useAppSelector } from "../redux/store"; +import ThumbnailSelect from "./ThumbnailSelect"; +import ThumbnailGeneration from "./ThumbnailGeneration"; +import { selectIsDisplayEditView } from "../redux/thumbnailSlice"; +import { selectPrimaryThumbnailTrack } from "../redux/videoSlice"; import { settings } from "../config"; -import { - basicButtonStyle, deactivatedButtonStyle, titleStyle, titleStyleBold, videosStyle, - backgroundBoxStyle, -} from "../cssStyles"; -import { Theme, useTheme } from "../themes"; -import { - selectOriginalThumbnails, - selectVideos, - selectTracks, - setHasChanges, - setThumbnail, - setThumbnails, -} from "../redux/videoSlice"; -import { Track } from "../types"; -import Timeline from "./Timeline"; -import { - selectIsPlaying, - selectIsMuted, - selectVolume, - selectCurrentlyAt, - setIsPlaying, - selectIsPlayPreview, - setIsPlayPreview, - setClickTriggered, - setIsMuted, - setVolume, - setCurrentlyAt, - jumpToPreviousSegment, - jumpToNextSegment, -} from "../redux/videoSlice"; -import { ThemedTooltip } from "./Tooltip"; -import VideoPlayers, { VideoPlayerForwardRef } from "./VideoPlayers"; -import VideoControls from "./VideoControls"; -import { ProtoButton } from "@opencast/appkit"; - /** - * User interface for handling thumbnails + * A container for the various thumbnail views */ const Thumbnail: React.FC = () => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - - const theme = useTheme(); - const originalThumbnails = useAppSelector(selectOriginalThumbnails); - - // Generate Refs - const generateRefs = React.useRef<(VideoPlayerForwardRef | null)[]>([]); - // 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; - } - - const reader = new FileReader(); - reader.onload = e => { - // the result image data - if (e.target && e.target.result) { - const uri = e.target.result as string; // We know this must be string because we use "readAsDataURL" - 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 => e.id === id)?.uri })); - }; - - const thumbnailStyle = css({ - display: "flex", - width: "100%", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - }); - - const bottomStyle = css({ - display: "flex", - width: "100%", - flexDirection: "column", - alignItems: "center", - }); - - return ( -
-
{t("thumbnail.title")}
- -
- {/* use maxHeightInPixel to make video players the same size*/} - -
- - -
-
-
- ); -}; - -/** - * A table for displaying thumbnails and associated actions - */ -const ThumbnailTable: React.FC<{ - inputRefs: React.MutableRefObject<(HTMLInputElement | null)[]>, - generateRefs: React.MutableRefObject<(VideoPlayerForwardRef | null)[]>, - generate: (track: Track, index: number) => void, - upload: (index: number) => void, - uploadCallback: (event: React.ChangeEvent, track: Track) => void, - discard: (id: string) => void, -}> = ({ inputRefs, generateRefs, generate, upload, uploadCallback, discard }) => { - - const videoTracks = useAppSelector(selectVideos); - - const thumbnailTableStyle = css({ - display: "flex", - width: "100%", - flexDirection: "row", - justifyContent: "center", - gap: "10px", - paddingBottom: "20px", - }); - - const renderSingleOrMultiple = () => { - const primaryTrack = videoTracks.find(e => e.thumbnailPriority === 0); + const displayEditView = useAppSelector(selectIsDisplayEditView); + const primaryTrack = useAppSelector(selectPrimaryThumbnailTrack); + const render = () => { if (settings.thumbnail.simpleMode && primaryTrack !== undefined) { - return ( -
- -
- ); - } else { - return (<> - {videoTracks.length > 1 && - - } -
- {videoTracks.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: React.MutableRefObject<(HTMLInputElement | null)[]>, - generateRef: VideoPlayerForwardRef | null, - generate: (track: Track, index: number) => void, - upload: (index: number) => void, - uploadCallback: (event: React.ChangeEvent, track: Track) => void, - discard: (id: string) => void, -}> = ({ track, index, inputRefs, generateRef, generate, upload, uploadCallback, discard }) => { - - const { t } = useTranslation(); - const theme = useTheme(); - - const videoWidth = generateRef ? - // The "+40" comes from padding that is not included in the "getWidth" function - generateRef.getWidth() + 40 : - // Random default - 740; - - 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 ; } + return displayEditView ? : ; }; - 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 theme = useTheme(); - - const generalStyle = css({ - width: "100%", - maxWidth: "740px", - aspectRatio: "16/9", - }); - - const imageStyle = css({ - maxWidth: "457px", - }); - - const placeholderStyle = css({ - width: "100vw", // TODO: This is necessary to make the placeholder large enough, but prevents it from shrinking - maxWidth: "457px", - backgroundColor: "grey", - display: "flex", - justifyContent: "center", - alignItems: "center", - color: `${theme.text}`, - }); - return ( <> - {(track.thumbnailUri !== null && track.thumbnailUri !== undefined) ? - // Thumbnail image - {t("thumbnail.previewImageAlt") - : - // Placeholder -
- {t("thumbnail.noThumbnailAvailable")} -
- } + {render()} ); }; -/** - * Buttons and actions related to thumbnails for a given track - */ -const ThumbnailButtons: React.FC<{ - track: Track, - index: number, - inputRefs: React.MutableRefObject<(HTMLInputElement | null)[]>, - generate: (track: Track, index: number) => void, - upload: (index: number) => void, - uploadCallback: (event: React.ChangeEvent, track: Track) => void, - discard: (id: string) => void, -}> = ({ track, index, inputRefs, generate, upload, uploadCallback, discard }) => { - - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - - const tracks = useAppSelector(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)); - }; - - const verticalLineStyle = css({ - borderTop: "1px solid #DDD;", - width: "100%", - }); - - return ( -
- { generate(track, index); }} - text={t("thumbnail.buttonGenerate")} - tooltipText={t("thumbnail.buttonGenerate-tooltip")} - ariaLabel={t("thumbnail.buttonGenerate-tooltip-aria")} - Icon={LuCamera} - active={true} - /> -
- { upload(index); }} - text={t("thumbnail.buttonUpload")} - tooltipText={t("thumbnail.buttonUpload-tooltip")} - ariaLabel={t("thumbnail.buttonUpload-tooltip-aria")} - Icon={LuUpload} - active={true} - /> - {/* Hidden input field for upload */} - { - inputRefs.current[index] = el; - }} - type="file" - accept="image/*" - onChange={event => uploadCallback(event, track)} - aria-hidden="true" - /> -
- {tracks.length > 1 && - <> - { setForOtherThumbnails(track.thumbnailUri); }} - text={t("thumbnail.buttonUseForOtherThumbnails")} - tooltipText={t("thumbnail.buttonUseForOtherThumbnails-tooltip")} - ariaLabel={t("thumbnail.buttonUseForOtherThumbnails-tooltip-aria")} - Icon={LuCopy} - 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={LuCircleX} - active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true : false)} - /> -
- ); -}; - -const ThumbnailButton: React.FC<{ - handler: () => void, - text: string; - tooltipText: string, - ariaLabel: string, - Icon: IconType, - active: boolean, -}> = ({ handler, text, tooltipText, ariaLabel, Icon, active }) => { - const theme = useTheme(); - const ref = React.useRef(null); - - const clickHandler = () => { - if (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: (track: Track, index: number) => void; -}> = ({ generate, tracks }) => { - - const { t } = useTranslation(); - const theme = useTheme(); - - 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", - }); - - const buttonStyle = css({ - height: "100%", - minWidth: "200px", - boxShadow: `${theme.boxShadow}`, - background: `${theme.element_bg}`, - }); - - return ( -
- - { - generateAll(); - }} - css={[basicButtonStyle(theme), buttonStyle]} - > - - {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: React.MutableRefObject<(HTMLInputElement | null)[]>, - generate: (track: Track, index: number) => void, - upload: (index: number) => void, - uploadCallback: (event: React.ChangeEvent, track: Track) => void, - discard: (id: string) => void, -}> = ({ track, index, inputRefs, generate, upload, uploadCallback, discard }) => { - const { t } = useTranslation(); - const theme = useTheme(); - - return ( -
-
- {t("thumbnailSimple.rowTitle")} -
-
-
- - -
-
- ); -}; - -/** - * Buttons for simple mode. Shows a generate button for each video - */ -const ThumbnailButtonsSimple: React.FC<{ - track: Track, - index: number, - inputRefs: React.MutableRefObject<(HTMLInputElement | null)[]>, - generate: (track: Track, index: number) => void, - upload: (index: number) => void, - uploadCallback: (event: React.ChangeEvent, track: Track) => void, - discard: (id: string) => void, -}> = ({ track, index, generate, inputRefs, upload, uploadCallback, discard }) => { - - const { t } = useTranslation(); - const tracks = useAppSelector(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={LuCamera} - active={true} - key={generateIndex} - /> - ))} - { upload(index); }} - text={t("thumbnail.buttonUpload")} - tooltipText={t("thumbnail.buttonUpload-tooltip")} - ariaLabel={t("thumbnail.buttonUpload-tooltip-aria")} - Icon={LuUpload} - 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={LuCircleX} - active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true : false)} - /> -
- ); -}; - -/** - * CSS shared between multi and simple display mode - */ -const thumbnailTableRowStyle = (maxWidth: number) => css({ - display: "flex", - flexDirection: "column", - width: "100%", - maxWidth: `${maxWidth}px`, -}); - -const thumbnailTableRowTitleStyle = css({ - textAlign: "left", - fontSize: "larger", - fontWeight: "bold", - "&:first-letter": { - textTransform: "capitalize", - }, -}); - -const thumbnailTableRowRowStyle = css({ - display: "flex", - flexDirection: "row", - gap: "20px", - - justifyContent: "space-around", - flexWrap: "wrap", -}); - -const thumbnailButtonsStyle = css({ - // TODO: Avoid hard-coding max-width - "@media (max-width: 1550px)": { - width: "100%", - }, - display: "flex", - flexDirection: "column", -}); - -const thumbnailButtonStyle = (active: boolean, theme: Theme) => [ - active ? basicButtonStyle(theme) : deactivatedButtonStyle, - { - width: "100%", - height: "100%", - background: `${theme.element_bg}`, - justifySelf: "center", - alignSelf: "center", - padding: "0px 4px", - }, -]; - - export default Thumbnail; diff --git a/src/main/ThumbnailGeneration.tsx b/src/main/ThumbnailGeneration.tsx new file mode 100644 index 000000000..526d914a9 --- /dev/null +++ b/src/main/ThumbnailGeneration.tsx @@ -0,0 +1,361 @@ +import { css } from "@emotion/react"; +import { LuCamera, LuChevronLeft } from "react-icons/lu"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { settings } from "../config"; +import { + basicButtonStyle, titleStyle, titleStyleBold, videosStyle, + backgroundBoxStyle, +} from "../cssStyles"; +import { useTheme } from "../themes"; +import { + selectVideos, + setHasChanges, + setThumbnail, + selectCurrentlyAtInSeconds, + selectPreviewTriggered, + selectClickTriggered, + selectJumpTriggered, + selectAspectRatio, + setPreviewTriggered, + setJumpTriggered, + setAspectRatio, + selectPrimaryThumbnailTrack, +} from "../redux/videoSlice"; +import { Track } from "../types"; +import Timeline from "./Timeline"; +import { + selectIsPlaying, + selectIsMuted, + selectVolume, + selectCurrentlyAt, + setIsPlaying, + selectIsPlayPreview, + setIsPlayPreview, + setClickTriggered, + setIsMuted, + setVolume, + setCurrentlyAt, + jumpToPreviousSegment, + jumpToNextSegment, +} from "../redux/videoSlice"; +import { ThemedTooltip } from "./Tooltip"; +import { VideoPlayer, VideoPlayerForwardRef } from "./VideoPlayers"; +import VideoControls from "./VideoControls"; +import { ProtoButton } from "@opencast/appkit"; +import { + DiscardButton, + ThumbnailButton, + thumbnailTableRowTitleStyle, + UploadButton, +} from "./ThumbnailSelect"; +import { selectIndex, setIsDisplayEditView } from "../redux/thumbnailSlice"; + + +/** + * Generate thumbnail from the track by timestamp + */ +const ThumbnailGeneration: React.FC = () => { + + const { t } = useTranslation(); + + const theme = useTheme(); + + const videoTracks = useAppSelector(selectVideos); + const index = useAppSelector(selectIndex); + const primaryTrack = useAppSelector(selectPrimaryThumbnailTrack); + + const track = videoTracks[index]; + + // Generate Refs + const generateRefs = React.useRef<(VideoPlayerForwardRef | null)[]>([]); + + + const thumbnailStyle = css({ + display: "flex", + width: "100%", + height: "100%", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + }); + + const headerRowStyle = css({ + position: "relative", + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + width: "100%", + }); + + const displayAreaStyle = css({ + display: "flex", + flex: "1 1 0", + width: "100%", + maxWidth: "740px", + minHeight: "0", + flexDirection: "column", + alignItems: "center", + justifySelf: "stretch", + gap: "18px", + }); + + const playerStyle = css({ + // Mostly just disabling the default styling with this + aspectRatio: "16 / 9", + width: "90% !important", + }); + + const horizontalLineStyle = css({ + borderTop: "1px solid #DDD;", + width: "100%", + }); + + return ( +
+
+ { !(settings.thumbnail.simpleMode && primaryTrack !== undefined) && + + } +
{t("thumbnail.title")}
+
+
+
+ {track.flavor.type} +
+ { + if (generateRefs === undefined) { return; } + (generateRefs.current[index] = el); + }} + url={track.uri} + isPrimary={true} + subtitleUrl={""} + first={true} + last={true} + overwritePlayerCSS={playerStyle} + selectIsPlaying={selectIsPlaying} + selectIsMuted={selectIsMuted} + selectCurrentlyAtInSeconds={selectCurrentlyAtInSeconds} + selectPreviewTriggered={selectPreviewTriggered} + selectClickTriggered={selectClickTriggered} + selectJumpTriggered={selectJumpTriggered} + selectAspectRatio={selectAspectRatio} + setIsPlaying={setIsPlaying} + selectVolume={selectVolume} + setPreviewTriggered={setPreviewTriggered} + setClickTriggered={setClickTriggered} + setJumpTriggered={setJumpTriggered} + setCurrentlyAt={setCurrentlyAt} + setAspectRatio={setAspectRatio} + /> +
+ +
+
+ + + +
+
+ ); +}; + +const ThumbnailActions: React.FC<{ + generateRefs: React.MutableRefObject<(VideoPlayerForwardRef | null)[]>, + track: Track, + index: number +}> = ({ + generateRefs, + track, + index, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const primaryTrack = useAppSelector(selectPrimaryThumbnailTrack); + + // 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)); + }; + + const thumbnailActionsStyle = css({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + + flexWrap: "wrap", + }); + + const thumbnailButtonStyle = css({ + padding: "16px", + fontSize: "20px", + fontWeight: "bold", + }); + + const verticalLineStyle = css({ + borderLeft: "2px solid #DDD;", + height: "32px", + }); + + return ( +
+ { (settings.thumbnail.simpleMode && primaryTrack !== undefined) && + <> +
+ + + } +
+ { generate(track, index); }} + text={t("thumbnail.buttonGenerate")} + tooltipText={t("thumbnail.buttonGenerate-tooltip")} + ariaLabel={t("thumbnail.buttonGenerate-tooltip-aria")} + Icon={LuCamera} + active={true} + index={0} + overwriteCSS={css([basicButtonStyle(theme), thumbnailButtonStyle])} + /> +
+ { (settings.thumbnail.simpleMode && primaryTrack !== undefined) && + <> + +
+ + } + +
+ ); +}; + +/** + * Copied from ThumbnailSelect to fine tune some CSS + */ +const ThumbnailDisplayer: React.FC<{ + track: Track +}> = ({ + track, +}) => { + + const { t } = useTranslation(); + const theme = useTheme(); + + const generalStyle = css({ + width: "100%", + maxWidth: "540px", + aspectRatio: "16/9", + flex: "1 1 0", + minHeight: "0", + objectFit: "contain", + }); + + const imageStyle = css({ + maxWidth: "457px", + }); + + const placeholderStyle = css({ + width: "100vw", // TODO: This is necessary to make the placeholder large enough, but prevents it from shrinking + maxWidth: "457px", + backgroundColor: "grey", + display: "flex", + justifyContent: "center", + alignItems: "center", + color: `${theme.text}`, + }); + + return ( + <> + {(track.thumbnailUri !== null && track.thumbnailUri !== undefined) ? + // Thumbnail image + {t("thumbnail.previewImageAlt") + : + // Placeholder +
+ {t("thumbnail.noThumbnailAvailable")} +
+ } + + ); +}; + +/** + * Takes you to a different page + */ +export const BackButton: React.FC = () => { + + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + + const backButtonStyle = css({ + position: "absolute", + top: "20%", + left: "2%", + background: "", + padding: "8px", + }); + + return ( + + dispatch(setIsDisplayEditView(false))} + css={[basicButtonStyle(theme), backButtonStyle]} + > + + {t("thumbnail.backButton")} + + + ); +}; + + +export default ThumbnailGeneration; diff --git a/src/main/ThumbnailSelect.tsx b/src/main/ThumbnailSelect.tsx new file mode 100644 index 000000000..857a927fd --- /dev/null +++ b/src/main/ThumbnailSelect.tsx @@ -0,0 +1,472 @@ +import { css, SerializedStyles } from "@emotion/react"; +import { IconType } from "react-icons"; +import { LuCamera, LuCopy, LuCircleX, LuUpload } from "react-icons/lu"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useAppDispatch, useAppSelector } from "../redux/store"; +import { + basicButtonStyle, deactivatedButtonStyle, + backgroundBoxStyle, +} from "../cssStyles"; +import { Theme, useTheme } from "../themes"; +import { + selectOriginalThumbnails, + selectVideos, + selectTracks, + setHasChanges, + setThumbnail, + setThumbnails, +} from "../redux/videoSlice"; +import { Track } from "../types"; +import { ThemedTooltip } from "./Tooltip"; +import { ProtoButton } from "@opencast/appkit"; +import { setIndex, setIsDisplayEditView } from "../redux/thumbnailSlice"; + +/** + * Choose between various thumbnail actions for the available tracks. + */ +const ThumbnailSelect: React.FC = () => { + + const videoTracks = useAppSelector(selectVideos); + + const thumbnailSelectStyle = css({ + display: "flex", + width: "100%", + height: "100%", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + flexWrap: "wrap", + gap: "60px", + }); + + return ( +
+ {videoTracks.map((track: Track, index: number) => ( + + ))} +
+ ); +}; + +/** + * Component for a single track + */ +const ThumbnailSelector: React.FC<{ + track: Track, + trackIndex: number, +}> = ({ track, trackIndex }) => { + + const { t } = useTranslation(); + const theme = useTheme(); + + 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; + } + }; + + const thumbnailSelectorStyle = (maxWidth: number) => css({ + display: "flex", + flexDirection: "column", + alignItems: "center", + width: "100%", + maxWidth: `${maxWidth}px`, + padding: "14px 20px", + gap: "0px", + }); + + 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 theme = useTheme(); + + const generalStyle = css({ + width: "100%", + maxWidth: "540px", + aspectRatio: "16/9", + }); + + const imageStyle = css({ + maxWidth: "457px", + }); + + const placeholderStyle = css({ + width: "100vw", // TODO: This is necessary to make the placeholder large enough, but prevents it from shrinking + maxWidth: "457px", + backgroundColor: "grey", + display: "flex", + justifyContent: "center", + alignItems: "center", + color: `${theme.text}`, + }); + + 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, + trackIndex: number, +}> = ({ track, trackIndex }) => { + + const tracks = useAppSelector(selectTracks); + + const thumbnailButtonsStyle = css({ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "1fr 1fr", + width: "100%", + maxWidth: "457px", + }); + + return ( +
+ + + + {tracks.length > 1 && + + } +
+ ); +}; + +/** + * Button for switching to generation view + */ +const ToGenerationButton: React.FC<{ + trackIndex: number, + index: number, +}> = ({ trackIndex, index }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + // Switch views + const switchToGeneration = (index: number) => { + dispatch(setIsDisplayEditView(true)); + dispatch(setIndex(index)); + }; + + return ( + { switchToGeneration(trackIndex); }} + text={t("thumbnail.buttonGenerate")} + tooltipText={t("thumbnail.buttonGenerate-tooltip")} + ariaLabel={t("thumbnail.buttonGenerate-tooltip-aria")} + Icon={LuCamera} + active={true} + index={index} + /> + ); +}; + +/** + * Button for uploading a thumbnail + */ +export const UploadButton: React.FC<{ + track: Track, + index: number, + overwriteCSS?: SerializedStyles +}> = ({ track, index, overwriteCSS }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + // Upload Refs + const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]); + + // 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; + } + + const reader = new FileReader(); + reader.onload = e => { + // the result image data + if (e.target && e.target.result) { + const uri = e.target.result as string; // We know this must be string because we use "readAsDataURL" + dispatch(setThumbnail({ id: track.id, uri: uri })); + dispatch(setHasChanges(true)); + } + }; + reader.readAsDataURL(fileObj); + }; + + return ( + <> + { upload(index); }} + text={t("thumbnail.buttonUpload")} + tooltipText={t("thumbnail.buttonUpload-tooltip")} + ariaLabel={t("thumbnail.buttonUpload-tooltip-aria")} + Icon={LuUpload} + active={true} + index={0} + overwriteCSS={overwriteCSS} + /> + {/* Hidden input field for upload */} + { + inputRefs.current[index] = el; + }} + type="file" + accept="image/*" + onChange={event => uploadCallback(event, track)} + aria-hidden="true" + /> + + ); +}; + +/** + * Button for undoing any changes made + */ +export const DiscardButton: React.FC<{ + track: Track, + index: number, + overwriteCSS?: SerializedStyles +}> = ({ track, index, overwriteCSS }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const originalThumbnails = useAppSelector(selectOriginalThumbnails); + + const discardThumbnail = (id: string) => { + dispatch(setThumbnail({ id: id, uri: originalThumbnails.find(e => e.id === id)?.uri })); + }; + + return ( + { discardThumbnail(track.id); }} + text={t("thumbnail.buttonDiscard")} + tooltipText={t("thumbnail.buttonDiscard-tooltip")} + ariaLabel={t("thumbnail.buttonDiscard-tooltip-aria")} + Icon={LuCircleX} + active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true : false)} + index={index} + overwriteCSS={overwriteCSS} + /> + ); +}; + +/** + * Button that sets this thumbnail for all tracks + */ +export const UseForAllTracksButton: React.FC<{ + track: Track, + index: number, +}> = ({ track, index }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const tracks = useAppSelector(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 ( + { setForOtherThumbnails(track.thumbnailUri); }} + text={t("thumbnail.buttonUseForOtherThumbnails")} + tooltipText={t("thumbnail.buttonUseForOtherThumbnails-tooltip")} + ariaLabel={t("thumbnail.buttonUseForOtherThumbnails-tooltip-aria")} + Icon={LuCopy} + active={(track.thumbnailUri && track.thumbnailUri.startsWith("data") ? true : false)} + index={index} + /> + ); +}; + +/** + * Base for the various thumbnail buttons + */ +export const ThumbnailButton: React.FC<{ + handler: () => void, + text: string; + tooltipText: string, + ariaLabel: string, + Icon: IconType, + index: number, + active: boolean, + overwriteCSS?: SerializedStyles +}> = ({ handler, text, tooltipText, ariaLabel, Icon, active, index, overwriteCSS }) => { + const theme = useTheme(); + const ref = React.useRef(null); + + const clickHandler = () => { + if (active) { handler(); } + ref.current?.blur(); + }; + const keyHandler = (event: React.KeyboardEvent) => { + if (active && (event.key === " " || event.key === "Enter")) { + handler(); + } + }; + + const thumbnailButtonStyle = (active: boolean, theme: Theme, index: number) => css([ + active ? basicButtonStyle(theme) : deactivatedButtonStyle, + { + width: "100%", + minHeight: "60px", + height: "100%", + background: `${theme.element_bg}`, + padding: "10px 20px", + fontSize: "20px", + fontWeight: "bold", + + position: "relative", + /* Vertical line (after 1st & 3rd items) */ + ...(index === 0 || index === 2 + ? { + "&::after": { + content: '""', + position: "absolute", + top: "10%", + bottom: "10%", + right: 0, + width: "1px", + backgroundColor: "#DDD", + pointerEvents: "none", + }, + } + : {}), + /* Horizontal line (below 1st & 2nd items) */ + ...(index === 0 || index === 1 + ? { + "&::before": { + content: '""', + position: "absolute", + left: "10%", + right: "10%", + bottom: 0, + height: "1px", + backgroundColor: "#DDD", + pointerEvents: "none", + }, + } + : {}), + }, + ]); + + return ( + + + + {text} + + + ); +}; + +/** + * Shared CSS + */ +export const thumbnailTableRowTitleStyle = css({ + textAlign: "center", + fontSize: "larger", + fontWeight: "bold", + "&:first-letter": { + textTransform: "capitalize", + }, + paddingBottom: "14px", +}); + + + +export default ThumbnailSelect; diff --git a/src/main/VideoPlayers.tsx b/src/main/VideoPlayers.tsx index a940a259e..89e701fd2 100644 --- a/src/main/VideoPlayers.tsx +++ b/src/main/VideoPlayers.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useImperativeHandle } from "react"; -import { css } from "@emotion/react"; +import { css, SerializedStyles } from "@emotion/react"; import { AppDispatch, useAppDispatch, useAppSelector } from "../redux/store"; import { @@ -125,6 +125,7 @@ interface VideoPlayerProps { subtitleUrl: string, first: boolean, last: boolean, + overwritePlayerCSS?: SerializedStyles, selectIsPlaying: (state: RootState) => boolean, selectIsMuted: (state: RootState) => boolean, selectVolume: (state: RootState) => number, @@ -167,6 +168,7 @@ export const VideoPlayer = React.forwardRef ) => { + state.isDisplayEditView = action.payload; + }, + setIndex: (state, action: PayloadAction) => { + state.index = action.payload; + }, + }, + selectors: { + selectIsDisplayEditView: state => state.isDisplayEditView, + selectIndex: state => state.index, + }, +}); + +// Export Actions +export const { setIsDisplayEditView, setIndex } = thumbnailSlice.actions; + +export const { selectIsDisplayEditView, selectIndex } = thumbnailSlice.selectors; + +export default thumbnailSlice.reducer; diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts index db4dc4296..787b1a4ef 100644 --- a/src/redux/videoSlice.ts +++ b/src/redux/videoSlice.ts @@ -446,6 +446,15 @@ const videoSlice = createSlice({ const displayDuration = (1 - state.timelineZoom) * (durationInSeconds - minDisplayTime) + minDisplayTime; return displayDuration; }, + selectPrimaryThumbnailTrack: state => { + const videos = state.tracks.filter((track: Track) => track.video_stream.available === true); + const primaryTrack = videos.reduce((min, curr) => { + if (curr.thumbnailPriority < 0) { return min; } + if (!min || curr.thumbnailPriority < min.thumbnailPriority) { return curr; } + return min; + }, undefined); + return primaryTrack; + }, }, }); @@ -633,6 +642,7 @@ export const { selectChaptersFromOpencastById, selectVideos, selectDisplayDuration, + selectPrimaryThumbnailTrack, } = videoSlice.selectors; export default videoSlice.reducer;