Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add upload subtitle button #1229

Merged
merged 12 commits into from
Jun 10, 2024
35 changes: 30 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"@fontsource-variable/roboto-flex": "^5.0.15",
"@iarna/toml": "^2.2.5",
"@mui/material": "^5.15.19",
"@opencast/appkit": "^0.2.4",
"@opencast/appkit": "^0.3.0",
"@reduxjs/toolkit": "^2.2.5",
"@testing-library/jest-dom": "^6.4.5",
"@types/iarna__toml": "^2.0.5",
Expand Down
16 changes: 15 additions & 1 deletion src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@
"subtitles": {
"selectSubtitleButton-tooltip": "Edit subtitles for {{title}}",
"selectSubtitleButton-tooltip-aria": "Select {{title}} for subtitle editing",
"createSubtitleButton-tooltip": "Opens a dialog for creating new subtitles",
"createSubtitleButton-tooltip": "Opens a dialog for creating/uploading new subtitles",
"createSubtitleButton-clicked-tooltip-aria": "Contains a dialog for creating new subtitles",
"createSubtitleButton-createButton": "Create",
"createSubtitleButton-createButton-tooltip": "Start a new subtitle file with the chosen title.",
Expand All @@ -257,6 +257,13 @@
"backButton-tooltip": "Return to subtitle selection",
"downloadButton-title": "Download",
"downloadButton-tooltip": "Download subtitle as vtt file",
"uploadButton-title": "Upload",
"uploadButton-tooltip": "Upload subtitle as vtt file",
"uploadButton-warning-header": "Caution!",
"uploadButton-warning": "Uploading will overwrite the current subtitle. This cannot be undone. Are you sure?",
"uploadButton-error": "Upload failed.",
"uploadButton-error-filetype": "Wrong file type.",
"uploadButton-error-parse": "Could not parse subtitle file. Please ensure that the file contains valid WebVTT.",
"editTitle": "Subtitle Editor - {{title}}",
"editTitle-loading": "Loading",
"generic": "Generic",
Expand Down Expand Up @@ -311,5 +318,12 @@

"language": {
"language": "Language"
},

"modal": {
"areYouSure": "Are you sure?",
"cancel": "Cancel",
"close": "Close",
"confirm": "Confirm"
}
}
166 changes: 143 additions & 23 deletions src/main/SubtitleEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { css } from "@emotion/react";
import { basicButtonStyle, flexGapReplacementStyle } from "../cssStyles";
import { LuChevronLeft, LuDownload } from "react-icons/lu";
import { LuChevronLeft, LuDownload, LuUpload } from "react-icons/lu";
import {
selectSubtitlesFromOpencastById,
} from "../redux/videoSlice";
Expand All @@ -16,11 +16,12 @@ import {
import SubtitleVideoArea from "./SubtitleVideoArea";
import SubtitleTimeline from "./SubtitleTimeline";
import { useTranslation } from "react-i18next";
import { useTheme } from "../themes";
import { Theme, useTheme } from "../themes";
import { parseSubtitle, serializeSubtitle } from "../util/utilityFunctions";
import { ThemedTooltip } from "./Tooltip";
import { titleStyle, titleStyleBold } from "../cssStyles";
import { generateButtonTitle } from "./SubtitleSelect";
import { ConfirmationModal, ConfirmationModalHandle, Modal, ModalHandle } from "@opencast/appkit";

/**
* Displays an editor view for a selected subtitle file
Expand All @@ -35,6 +36,8 @@ const SubtitleEditor: React.FC = () => {
const selectedId = useAppSelector(selectSelectedSubtitleId);
const captionTrack = useAppSelector(state => selectSubtitlesFromOpencastById(state, selectedId));
const theme = useTheme();
const modalRef = React.useRef<ModalHandle>(null);
const [uploadErrorMessage, setUploadErrorMessage] = useState<string | undefined>(undefined);

// Prepare subtitle in redux
useEffect(() => {
Expand All @@ -61,6 +64,17 @@ const SubtitleEditor: React.FC = () => {
}
}, [dispatch, captionTrack, subtitle, selectedId]);

// Display error modal
useEffect(() => {
if (modalRef.current && uploadErrorMessage) {
modalRef.current?.open();
}
if (modalRef.current && modalRef.current.close && !uploadErrorMessage) {
setUploadErrorMessage(undefined);
modalRef.current.close();
}
}, [uploadErrorMessage]);

const getTitle = () => {
if (subtitle) {
return generateButtonTitle(subtitle.tags, t);
Expand All @@ -84,6 +98,14 @@ const SubtitleEditor: React.FC = () => {
justifyContent: "space-between",
alignItems: "center",
width: "100%",
...(flexGapReplacementStyle(10, false)),
padding: "15px",
});

const topRightButtons = css({
display: "flex",
flexDirection: "row",
...(flexGapReplacementStyle(10, false)),
});

const subAreaStyle = css({
Expand All @@ -99,7 +121,6 @@ const SubtitleEditor: React.FC = () => {
borderBottom: `${theme.menuBorder}`,
});


const render = () => {
if (getError !== undefined) {
return (
Expand All @@ -110,10 +131,20 @@ const SubtitleEditor: React.FC = () => {
<>
<div css={headerRowStyle}>
<BackButton />
<div css={[titleStyle(theme), titleStyleBold(theme)]}>
<div css={[titleStyle(theme), titleStyleBold(theme), { padding: "0px" }]}>
{t("subtitles.editTitle", { title: getTitle() })}
</div>
<DownloadButton />
<div css={topRightButtons}>
<UploadButton setErrorMessage={setUploadErrorMessage} />
<DownloadButton />
<Modal
ref={modalRef}
title={t("subtitles.uploadButton-error")}
text={{ close: t("modal.close") }}
>
{uploadErrorMessage}
</Modal>
</div>
</div>
<div css={subAreaStyle}>
<SubtitleListEditor />
Expand All @@ -132,6 +163,15 @@ const SubtitleEditor: React.FC = () => {
);
};

const subtitleButtonStyle = (theme: Theme) => css({
fontSize: "16px",
height: "10px",
padding: "16px",
justifyContent: "space-around",
boxShadow: `${theme.boxShadow}`,
background: `${theme.element_bg}`,
});

const DownloadButton: React.FC = () => {

const subtitle = useAppSelector(selectSelectedSubtitleById);
Expand All @@ -151,18 +191,10 @@ const DownloadButton: React.FC = () => {

const { t } = useTranslation();
const theme = useTheme();
const style = css({
fontSize: "16px",
height: "10px",
padding: "16px",
justifyContent: "space-around",
boxShadow: `${theme.boxShadow}`,
background: `${theme.element_bg}`,
});

return (
<ThemedTooltip title={t("subtitles.downloadButton-tooltip")}>
<div css={[basicButtonStyle(theme), style]}
<div css={[basicButtonStyle(theme), subtitleButtonStyle(theme)]}
role="button"
onClick={() => downloadSubtitles()}
>
Expand All @@ -173,6 +205,102 @@ const DownloadButton: React.FC = () => {
);
};

const UploadButton: React.FC<{
setErrorMessage: React.Dispatch<React.SetStateAction<string | undefined>>,
}> = ({
setErrorMessage,
}) => {

const { t } = useTranslation();
const theme = useTheme();
const dispatch = useAppDispatch();

const [isFileUploadTriggered, setisFileUploadTriggered] = useState(false);
const subtitle = useAppSelector(selectSelectedSubtitleById);
const selectedId = useAppSelector(selectSelectedSubtitleId);
// Upload Ref
const inputRef = React.useRef<HTMLInputElement>(null);
// Modal Ref
const modalRef = React.useRef<ConfirmationModalHandle>(null);

const triggerFileUpload = () => {
modalRef.current?.done();
setisFileUploadTriggered(true);
};

useEffect(() => {
if (isFileUploadTriggered) {
inputRef.current?.click();
setisFileUploadTriggered(false);
}
}, [isFileUploadTriggered]);

// Save uploaded file in redux
const uploadCallback = (event: React.ChangeEvent<HTMLInputElement>) => {
const fileObj = event.target.files && event.target.files[0];
if (!fileObj) {
return;
}

// Check if not text
if (fileObj.type.split("/")[0] !== "text") {
setErrorMessage(t("subtitles.uploadButton-error-filetype"));
return;
}

const reader = new FileReader();
reader.onload = e => {
if (e.target && (e.target.result || e.target.result === "")) {
try {
const text = e.target.result.toString();
const subtitleParsed = parseSubtitle(text);
dispatch(setSubtitle({ identifier: selectedId, subtitles: { cues: subtitleParsed, tags: subtitle.tags } }));
} catch (e) {
console.error(e);
setErrorMessage(t("subtitles.uploadButton-error-parse"));
}
}
};
reader.readAsText(fileObj);
};

return (
<>
<ThemedTooltip title={t("subtitles.uploadButton-tooltip")}>
<div css={[basicButtonStyle(theme), subtitleButtonStyle(theme)]}
role="button"
onClick={() => modalRef.current?.open()}
>
<LuUpload css={{ fontSize: "16px" }}/>
<span>{t("subtitles.uploadButton-title")}</span>
</div>
</ThemedTooltip>
{/* Hidden input field for upload */}
<input
style={{ display: "none" }}
ref={inputRef}
type="file"
accept="text/vtt"
onChange={event => uploadCallback(event)}
aria-hidden="true"
/>
<ConfirmationModal
title={t("subtitles.uploadButton-warning-header")}
buttonContent={t("modal.confirm")}
onSubmit={triggerFileUpload}
ref={modalRef}
text={{
cancel: t("modal.cancel"),
close: t("modal.close"),
areYouSure: t("modal.areYouSure"),
}}
>
{t("subtitles.uploadButton-warning")}
</ConfirmationModal>
</>
);
};


/**
* Takes you to a different page
Expand All @@ -183,17 +311,9 @@ export const BackButton: React.FC = () => {
const theme = useTheme();
const dispatch = useAppDispatch();

const backButtonStyle = css({
height: "10px",
padding: "16px",
boxShadow: `${theme.boxShadow}`,
background: `${theme.element_bg}`,
justifyContent: "space-around",
});

return (
<ThemedTooltip title={t("subtitles.backButton-tooltip")}>
<div css={[basicButtonStyle(theme), backButtonStyle]}
<div css={[basicButtonStyle(theme), subtitleButtonStyle(theme)]}
role="button" tabIndex={0}
aria-label={t("subtitles.backButton-tooltip")}
onClick={() => dispatch(setIsDisplayEditView(false))}
Expand Down
4 changes: 4 additions & 0 deletions src/util/utilityFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export function parseSubtitle(subtitle: string): SubtitleCue[] {
// - Pros: Parses styles, can also parse SRT, actively maintained
// - Cons: Uses node streaming library, can"t polyfill without ejecting CreateReactApp
// TODO: Parse caption
if (subtitle === "") {
throw new Error("File is empty");
}

const parser = new WebVTTParser();
const tree = parser.parse(subtitle, "metadata");
if (tree.errors.length !== 0) {
Expand Down
Loading