diff --git a/src/videoag/course/DownloadManager.tsx b/src/videoag/course/DownloadManager.tsx index bc6a8d2c785a3b1dd6fdcb9e2584e989feb80d99..92561b27a747225032c587138096f86109835384 100644 --- a/src/videoag/course/DownloadManager.tsx +++ b/src/videoag/course/DownloadManager.tsx @@ -3,15 +3,18 @@ import Modal from "react-bootstrap/Modal"; import DropdownButton from "react-bootstrap/DropdownButton"; import DropdownItem from "react-bootstrap/DropdownItem"; -import type { course, publish_medium } from "@/videoag/api/types"; +import type { int, course, lecture } from "@/videoag/api/types"; import { showWarningToast, showError } from "@/videoag/error/ErrorDisplay"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; import { filesizeToHuman } from "@/videoag/miscellaneous/Formatting"; import { showInfoToast } from "@/videoag/miscellaneous/Util"; -import { sortSourceNames, sortSources, sourceToName } from "./Player"; -import { sortPublishMediaByQuality, getNamedPublishMedia, getSortedDownloadablePublishMedia, getMediumQualityName } from "./Medium"; +import { + getNamedPublishMedia, + getSortedDownloadablePublishMedia, + getMediumQualityName, +} from "./Medium"; interface DownloadStatus { downloadedBytes: number; @@ -26,12 +29,12 @@ interface DownloadStatus { async function downloadToDirectoryHandle( replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, - filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + filesToDownload: { lectureId: int; url: string; filename: string; fileSize: int }[], failNowOnException: any, requestStopDownload: any, saveDir: any, - onFileDownloaded: (fileHandle: any) => Promise<void>, -): boolean { + onFileDownloaded: (fileHandle: any, filename: string) => Promise<void>, +): Promise<boolean> { for (const toDownload of filesToDownload) { try { if (requestStopDownload.current) { @@ -86,7 +89,7 @@ async function downloadToDirectoryHandle( error: "Download cancelled", })); } else { - await onFileDownloaded(fileHandle); + await onFileDownloaded(fileHandle, toDownload.filename); replaceStatus(toDownload.lectureId, () => ({ downloadedBytes, totalBytes, @@ -105,16 +108,17 @@ async function downloadToDirectoryHandle( async function downloadWithFileSystemAPI( replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, - filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + filesToDownload: { lectureId: int; url: string; filename: string; fileSize: int }[], failNowOnException: any, requestStopDownload: any, -): boolean { +): Promise<boolean> { if (!(window as any).showDirectoryPicker) { throw new Error("File System API unsupported"); } const saveDir = await (window as any).showDirectoryPicker(); if (!saveDir) { - return; + // User cancelled + return true; } await saveDir.requestPermission({ mode: "readwrite" }); @@ -125,28 +129,30 @@ async function downloadWithFileSystemAPI( failNowOnException, requestStopDownload, saveDir, - (fileHandle) => undefined, - ) + async (fileHandle, filename) => {}, + ); } async function downloadWithOPFSAPI( replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, - filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + filesToDownload: { lectureId: int; url: string; filename: string; fileSize: int }[], failNowOnException: any, requestStopDownload: any, -): boolean { +): Promise<boolean> { if (!navigator.storage || !navigator.storage.getDirectory) { throw new Error("OPFS Api unsupported"); } let saveDir = await navigator.storage.getDirectory(); if (!saveDir) { - return; + // User cancelled + return true; } // clear the directory saveDir.removeEntry("videoag", { recursive: true }); saveDir = await saveDir.getDirectoryHandle("videoag", { create: true }); if (!saveDir) { - return; + // User cancelled + return true; } return downloadToDirectoryHandle( @@ -155,7 +161,7 @@ async function downloadWithOPFSAPI( failNowOnException, requestStopDownload, saveDir, - async (fileHandle) => { + async (fileHandle, filename) => { // create a element to download the file let a = document.createElement("a"); a.href = URL.createObjectURL(await fileHandle.getFile()); @@ -163,17 +169,19 @@ async function downloadWithOPFSAPI( a.target = "_blank"; a.click(); }, - ) + ); } async function downloadWithDownloadAPI( replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, - filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + filesToDownload: { lectureId: int; url: string; filename: string; fileSize: int }[], failNowOnException: any, requestStopDownload: any, -): boolean { - showWarningToast("Unable to store files in directory automatically. Using basic download API which might require" + - " you to selected the destination location for every file."); +): Promise<boolean> { + showWarningToast( + "Unable to store files in directory automatically. Using basic download API which might require" + + " you to selected the destination location for every file.", + ); for (const toDownload of filesToDownload) { try { if (requestStopDownload.current) { @@ -203,21 +211,21 @@ async function downloadWithDownloadAPI( async function downloadMedia( downloadStatus: Map<int, DownloadStatus> | undefined, - setDownloadStatus: (fn: (old?: Map<int, DownloadStatus> | undefined) => Map<int, DownloadStatus> | undefined) => void, + setDownloadStatus: ( + fn: (old?: Map<int, DownloadStatus> | undefined) => Map<int, DownloadStatus> | undefined, + ) => void, course: course, chosenMediumIdByLectureId: Map<int, int>, failNowOnException: any, requestStopDownload: any, ) { - const filesToDownload = []; + const filesToDownload: { lectureId: int; url: string; filename: string; fileSize: int }[] = []; for (const lecture of course.lectures!) { const chosenMediumId = chosenMediumIdByLectureId.get(lecture.id); - if (chosenMediumId === undefined) - continue; + if (chosenMediumId === undefined) continue; const medium = lecture.publish_media!.find((m) => m.id == chosenMediumId); - if (!medium) - throw new Error(`Unable to find medium with id ${chosenMediumId} in lecture`); + if (!medium) throw new Error(`Unable to find medium with id ${chosenMediumId} in lecture`); filesToDownload.push({ lectureId: lecture.id, @@ -225,14 +233,14 @@ async function downloadMedia( // TODO file ending mp4 // TODO lecture date and not id filename: `videoag_${course.handle}_${lecture.id}_${getMediumQualityName(medium)}.mp4`, - fileSize: medium.file_size, + fileSize: medium.medium_metadata.file_size, }); } - setDownloadStatus(new Map()); + setDownloadStatus((oldStatus) => new Map<int, DownloadStatus>()); const replaceStatus = (lectureId: int, fn: (old?: DownloadStatus) => DownloadStatus) => { setDownloadStatus((oldStatus) => { - const newStatus = new Map(oldStatus); + const newStatus = new Map<int, DownloadStatus>(oldStatus); newStatus.set(lectureId, fn(oldStatus?.get(lectureId))); return newStatus; }); @@ -241,10 +249,12 @@ async function downloadMedia( console.log("Starting download..."); failNowOnException.current = false; const success = await downloadWithFileSystemAPI( - replaceStatus, - filesToDownload, - requestStopDownload, - ).catch((e) => { + replaceStatus, + filesToDownload, + failNowOnException, + requestStopDownload, + ) + .catch((e) => { console.log("Error with File System Api", e); if (failNowOnException.current) { showError(e, "An error occurred while downloading"); @@ -253,9 +263,11 @@ async function downloadMedia( return downloadWithOPFSAPI( replaceStatus, filesToDownload, + failNowOnException, requestStopDownload, ); - }).catch((e) => { + }) + .catch((e) => { console.log("Error with OPFS Api", e); if (failNowOnException.current) { showError(e, "An error occurred while downloading"); @@ -264,16 +276,18 @@ async function downloadMedia( return downloadWithDownloadAPI( replaceStatus, filesToDownload, + failNowOnException, requestStopDownload, ); - }).catch((e) => { + }) + .catch((e) => { console.log("Error with Download Api", e); showError(e, "An error occurred while downloading"); return false; }); - console.log(`Download finished. Success: ${success}`) + console.log(`Download finished. Success: ${success}`); if (success) { - showInfoToast("Download finished", true) + showInfoToast("Download finished"); } } @@ -286,7 +300,7 @@ function DownloadAllLectureListItem({ }: { lecture: lecture; selectedMediumId: int | undefined; - setSelectedMediumId: (int) => void; + setSelectedMediumId: (mediumId: int) => void; isDownloading: boolean; downloadStatus: DownloadStatus | undefined; }) { @@ -305,14 +319,13 @@ function DownloadAllLectureListItem({ </div> ); } else if (downloadStatus.error) { - displayedProgress = ( - <span className="text-danger"> - {downloadStatus.get(lecture.id)?.error} - </span> - ); + displayedProgress = <span className="text-danger">{downloadStatus.error}</span>; } else if (downloadStatus.done) { displayedProgress = <span className="text-success">Done</span>; } else { + const percent = Math.floor( + (downloadStatus.downloadedBytes / downloadStatus.totalBytes) * 100, + ); displayedProgress = ( <> <div className="progress" style={{ width: "100px" }}> @@ -320,24 +333,16 @@ function DownloadAllLectureListItem({ className="progress-bar" role="progressbar" style={{ - width: downloadStatus.percent + "%", + width: percent + "%", }} - aria-valuenow={downloadStatus.percent} + aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100} /> </div> <span> - {Math.round( - (downloadStatus.downloaded ?? 0) / - 1024 / - 1024, - )}{" "} - MB /{" "} - {Math.round( - (downloadStatus.total ?? 0) / 1024 / 1024, - )}{" "} - MB + {Math.round((downloadStatus.downloadedBytes ?? 0) / 1024 / 1024)} MB /{" "} + {Math.round((downloadStatus.totalBytes ?? 0) / 1024 / 1024)} MB </span> </> ); @@ -373,8 +378,9 @@ function DownloadAllLectureListItem({ </td> <td className={ - "col-6 make-span-overflow-scroll " + - selectedMediumId !== undefined ? "" : "text-secondary" + "col-6 make-span-overflow-scroll " + selectedMediumId !== undefined + ? "" + : "text-secondary" } > <StylizedText>{lecture.title}</StylizedText> @@ -384,7 +390,7 @@ function DownloadAllLectureListItem({ className="form-select" disabled={isDownloading || media.length == 0} value={selectedMediumId} - onChange={(e) => setSelectedMediumId(e.target.value)} + onChange={(e) => setSelectedMediumId(parseInt(e.target.value))} > {getNamedPublishMedia(media).map(({ name, medium }) => ( <option key={medium.id} value={medium.id}> @@ -400,7 +406,9 @@ function DownloadAllLectureListItem({ export default function DownloadAllModal({ course }: { course: course }) { const [showModal, setShowModal] = useState(false); - const [chosenMediumIdByLectureId, setChosenMediumIdByLectureId] = useState<Map<int, int>>(new Map()); + const [chosenMediumIdByLectureId, setChosenMediumIdByLectureId] = useState<Map<int, int>>( + new Map(), + ); const requestStopDownload = useRef(false); const failNowOnException = useRef(false); @@ -422,9 +430,8 @@ export default function DownloadAllModal({ course }: { course: course }) { const newChosenMedia = new Map<int, int>(); for (const lecture of course.lectures!) { - const publishMedia = getSortedDownloadablePublishMedia(lecture.publish_media!) - if (publishMedia.length > 0) - newChosenMedia.set(lecture.id, publishMedia[0].id); + const publishMedia = getSortedDownloadablePublishMedia(lecture.publish_media!); + if (publishMedia.length > 0) newChosenMedia.set(lecture.id, publishMedia[0].id); } setChosenMediumIdByLectureId(newChosenMedia); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -438,11 +445,10 @@ export default function DownloadAllModal({ course }: { course: course }) { for (const medium of media) { const qualityName = getMediumQualityName(medium); - if (!allQualityNames.includes(qualityName)) + if (qualityName && !allQualityNames.includes(qualityName)) allQualityNames.push(qualityName); - if (medium.id === selectedMediumId) - totalSize += medium.medium_metadata.file_size; + if (medium.id === selectedMediumId) totalSize += medium.medium_metadata.file_size; } } if (allQualityNames.length === 0) { @@ -455,11 +461,9 @@ export default function DownloadAllModal({ course }: { course: course }) { const newChosenMedia = new Map(chosenMediumIdByLectureId); for (const lecture of course.lectures!) { - if (newChosenMedia.get(lecture.id)) - continue; - const publishMedia = getSortedDownloadablePublishMedia(lecture.publish_media!) - if (publishMedia.length > 0) - newChosenMedia.set(lecture.id, publishMedia[0].id); + if (newChosenMedia.get(lecture.id)) continue; + const publishMedia = getSortedDownloadablePublishMedia(lecture.publish_media!); + if (publishMedia.length > 0) newChosenMedia.set(lecture.id, publishMedia[0].id); } setChosenMediumIdByLectureId(newChosenMedia); }; @@ -479,9 +483,8 @@ export default function DownloadAllModal({ course }: { course: course }) { const newChosenMedia = new Map(chosenMediumIdByLectureId); for (const lecture of course.lectures!) { - if (newChosenMedia.get(lecture.id) === undefined) - continue; - for (const medium of lecture.publish_media) { + if (newChosenMedia.get(lecture.id) === undefined) continue; + for (const medium of lecture.publish_media!) { if (getMediumQualityName(medium) === qualityToSet) newChosenMedia.set(lecture.id, medium.id); } @@ -533,8 +536,7 @@ export default function DownloadAllModal({ course }: { course: course }) { > <i className={ - "bi me-1 " + - (isDownloading ? "bi-pause-fill" : "bi-play-fill") + "bi me-1 " + (isDownloading ? "bi-pause-fill" : "bi-play-fill") } /> {isDownloading @@ -586,12 +588,16 @@ export default function DownloadAllModal({ course }: { course: course }) { style={{ tableLayout: "fixed" }} > <tbody> - {course.lectures.map((lecture) => ( + {course.lectures!.map((lecture) => ( <DownloadAllLectureListItem key={lecture.id} lecture={lecture} selectedMediumId={chosenMediumIdByLectureId.get(lecture.id)} - setSelectedMediumId={(medium_id) => setLectureToMediumId(lecture.id, medium_id)} + setSelectedMediumId={(medium_id) => + setLectureToMediumId(lecture.id, medium_id) + } + isDownloading={isDownloading} + downloadStatus={downloadStatus?.get(lecture.id)} /> ))} </tbody> diff --git a/src/videoag/course/Medium.tsx b/src/videoag/course/Medium.tsx index e61da73ce754e29b667e99e362c41b27262b41be..3a7a195ae5d9332c6030885724a398e2cf320652 100644 --- a/src/videoag/course/Medium.tsx +++ b/src/videoag/course/Medium.tsx @@ -1,17 +1,13 @@ import { Dropdown } from "react-bootstrap"; -import { - OMDelete, - OMEdit, - OMHistory, - EmbeddedOMFieldComponent, -} from "@/videoag/object_management/OMConfigComponent"; -import { useEditMode } from "@/videoag/object_management/EditModeProvider"; +import { publish_medium } from "@/videoag/api/types"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { filesizeToHuman } from "@/videoag/miscellaneous/Formatting"; +import { useEditMode } from "@/videoag/object_management/EditModeProvider"; +import { OMDelete, EmbeddedOMFieldComponent } from "@/videoag/object_management/OMConfigComponent"; export function getMediumQualityName(medium: publish_medium): string | undefined { - switch(medium.medium_metadata.type) { + switch (medium.medium_metadata.type) { case "plain_video": return `${medium.medium_metadata.vertical_resolution}p`; case "plain_audio": @@ -26,19 +22,16 @@ export interface NamedPublishMedium { medium: publish_medium; } - export function getNamedPublishMedia(media: publish_medium[]): NamedPublishMedium[] { // TODO Add more properties from metadata in case non-unique names are generated by this const namedMedia = []; for (const medium of media) { - const nameParts = [] - if (medium.title) - nameParts.push(medium.title); + const nameParts = []; + if (medium.title) nameParts.push(medium.title); const qualityName = getMediumQualityName(medium); - if (qualityName) - nameParts.push(qualityName); + if (qualityName) nameParts.push(qualityName); nameParts.push(`(${filesizeToHuman(medium.medium_metadata.file_size)})`); @@ -80,50 +73,50 @@ export function PublishMediumList({ publish_media }: { publish_media: publish_me return ( <ul className="p-0"> - {getNamedPublishMedia(sortPublishMediaByQuality(publish_media)).map(({ medium, name }) => ( - <li - key={medium.id} - className={ - "d-flex border align-items-center px-2 py-1 rounded " + - (medium.visible === false ? "bg-danger-subtle" : "") - } - > - <span className="bi bi-download" /> - <a href={medium.url} className="mx-1"> - {name} - </a> - {editMode && ( - <> - <div className="flex-fill" /> - <EmbeddedOMFieldComponent - object_type="publish_medium" - object_id={medium.id!} - field_id="visible" - field_type="boolean" - initialValue={medium.visible} - className="me-2 align-self-center py-0" - /> - <OMDelete - object_type="publish_medium" - object_id={medium.id!} - className="py-0" - /> - </> - )} - </li> - ))} + {getNamedPublishMedia(sortPublishMediaByQuality(publish_media)).map( + ({ medium, name }) => ( + <li + key={medium.id} + className={ + "d-flex border align-items-center px-2 py-1 rounded " + + (medium.visible === false ? "bg-danger-subtle" : "") + } + > + <span className="bi bi-download" /> + <a href={medium.url} className="mx-1"> + {name} + </a> + {editMode && ( + <> + <div className="flex-fill" /> + <EmbeddedOMFieldComponent + object_type="publish_medium" + object_id={medium.id!} + field_id="visible" + field_type="boolean" + initialValue={medium.visible} + className="me-2 align-self-center py-0" + /> + <OMDelete + object_type="publish_medium" + object_id={medium.id!} + className="py-0" + /> + </> + )} + </li> + ), + )} </ul> ); } export function PublishMediumDownloadButton({ publish_media, - className, direction, ...props }: { publish_media: publish_medium[]; - className?: string; direction: "up" | "down"; [key: string]: any; }) { @@ -131,50 +124,49 @@ export function PublishMediumDownloadButton({ const { language } = useLanguage(); const namedMedia = getNamedPublishMedia(getSortedDownloadablePublishMedia(publish_media)); - if(namedMedia.length === 0) - return (<></>); + if (namedMedia.length === 0) return <></>; return ( - <Dropdown drop={direction}> - <Dropdown.Toggle - variant="primary" - disabled={publish_media === undefined || publish_media.length === 0} - {...props} - > - {language.get("ui.generic.download")} - </Dropdown.Toggle> - - <Dropdown.Menu> - {namedMedia.map(({ medium, name }) => { - let bgColor = ""; - if (medium.visible === false) - bgColor = "bg-danger-subtle"; - - return ( - <div - key={medium.id} - className={"d-flex align-items-center " + bgColor} - > - <Dropdown.Item href={medium.url} download> - {name} - </Dropdown.Item> - {editMode && ( - <> - <EmbeddedOMFieldComponent - object_type="publish_medium" - object_id={medium.id!} - field_id="visible" - field_type="boolean" - initialValue={medium.visible} - className="me-2" - /> - <OMDelete object_type="publish_medium" object_id={medium.id!} /> - </> - )} - </div> - ); - })} - </Dropdown.Menu> - </Dropdown> + <span {...props}> + <Dropdown drop={direction}> + <Dropdown.Toggle + variant="primary" + disabled={publish_media === undefined || publish_media.length === 0} + > + {language.get("ui.generic.download")} + </Dropdown.Toggle> + + <Dropdown.Menu> + {namedMedia.map(({ medium, name }) => { + let bgColor = ""; + if (medium.visible === false) bgColor = "bg-danger-subtle"; + + return ( + <div key={medium.id} className={"d-flex align-items-center " + bgColor}> + <Dropdown.Item href={medium.url} download> + {name} + </Dropdown.Item> + {editMode && ( + <> + <EmbeddedOMFieldComponent + object_type="publish_medium" + object_id={medium.id!} + field_id="visible" + field_type="boolean" + initialValue={medium.visible} + className="me-2" + /> + <OMDelete + object_type="publish_medium" + object_id={medium.id!} + /> + </> + )} + </div> + ); + })} + </Dropdown.Menu> + </Dropdown> + </span> ); -} \ No newline at end of file +} diff --git a/src/videoag/form/TypeEditor.tsx b/src/videoag/form/TypeEditor.tsx index 176077727c773883f58512850abab67207b91c73..52deacfe6e0f2c013ff7bc3b0a58764f0538ac7a 100644 --- a/src/videoag/form/TypeEditor.tsx +++ b/src/videoag/form/TypeEditor.tsx @@ -30,7 +30,7 @@ export function StringEditor({ minLength?: int; maxLength?: int; allowUndefined?: boolean; - regex?: RegExp; + regex?: RegExp | string; regexInvalidMessage?: string | React.ReactNode; }) { useEffect(() => { @@ -43,7 +43,11 @@ export function StringEditor({ if (val === undefined) return allowUndefined === true; if (minLength !== undefined && val.length < minLength) return false; if (maxLength !== undefined && val.length > maxLength) return false; - if (regex !== undefined && !regex.test(val)) return false; + if (regex !== undefined) { + const regexRes = val.match(regex); + if (regexRes === null) return false; + if (regexRes[0].length != val.length) return false; // No full match + } return true; }; const update = (val: any, affectNow: boolean) => { diff --git a/src/videoag/miscellaneous/Util.tsx b/src/videoag/miscellaneous/Util.tsx index 9fc94cc2bfc42f8d3dd618371e873c30cd2ced39..92bfc59de61bfe22be0dbb508b638480461cd0bd 100644 --- a/src/videoag/miscellaneous/Util.tsx +++ b/src/videoag/miscellaneous/Util.tsx @@ -47,7 +47,7 @@ export function mapMap<K, V, R>(map: Map<K, V>, func: (val: V) => R): Map<K, R> return newMap; } -export function showInfoToast(message: React.ReactNode, autoClose: boolean) { +export function showInfoToast(message: React.ReactNode, autoClose: number | false = 5000) { toast.info(message, { position: "top-center", autoClose: autoClose, diff --git a/src/videoag/object_management/OMConfigComponent.tsx b/src/videoag/object_management/OMConfigComponent.tsx index bfbb29a83f8e5ca9ab0d8eb27062349afff05df8..b10e96d21d6efda22d4041ca724abec02828d9e6 100644 --- a/src/videoag/object_management/OMConfigComponent.tsx +++ b/src/videoag/object_management/OMConfigComponent.tsx @@ -250,9 +250,12 @@ export function EmbeddedOMFieldComponent({ switch (field_type) { case "string": if (editMode && !value) - formatted = <p><i className="text-muted">{"<empty>"}</i></p> - else - formatted = <StylizedText markdown={allowMarkdown}>{value}</StylizedText>; + formatted = ( + <p> + <i className="text-muted">{"<empty>"}</i> + </p> + ); + else formatted = <StylizedText markdown={allowMarkdown}>{value}</StylizedText>; break; case "datetime": formatted = datetimeToString(value);