diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 311fe0a0333fd8323ceb65bfff78880a39bf0ebc..ba3ce608de70528ee27aaf32fd57c663bb9a2c18 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/router"; import type { GetHomepageResponse, featured, int } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; import { useUserContext } from "@/videoag/authentication/UserDataProvider"; -import { VideoCard } from "@/videoag/course/VideoCard"; +import { LectureCard } from "@/videoag/course/Lecture"; import { LectureLiveLabel } from "@/videoag/course/LiveLabel"; import { ErrorComponent, showError, showWarningToast } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; @@ -234,7 +234,7 @@ function RecentUploads({ homepageData }: { homepageData: ResourceType<GetHomepag let course_data = data.course_context[lecture.course_id]; return ( <li className="list-group-item" key={index}> - <VideoCard lecture={lecture} course={course_data} />{" "} + <LectureCard lecture={lecture} course={course_data} />{" "} </li> ); })} diff --git a/src/styles/globals.scss b/src/styles/globals.scss index cb601054e47cfa894ed25a1a2e59653dd66385af..a87f1c888b1382564197a669d60af866d0875ceb 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -164,6 +164,36 @@ $link-decoration: none; transition: none !important; } + +.play-button { + // Parents needs to have position: relative + position: absolute; + width: 100%; + height: 100%; + left: 0; + text-align: center; + font-size: 200%; + line-height: 400%; +} + +.centered-overlay-container { + // Parents needs to have position: relative + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + display: flex; + justify-content: center; + align-items: center; +} + +.center-background-img { + background-size: contain; + background-repeat: no-repeat; + background-position: 50% 50%; +} + .vjs-rwth.video-js { .vjs-control-bar { diff --git a/src/styles/legacy_style.css b/src/styles/legacy_style.css index 2990a710eb0f695926063528211e52ac2fc31e44..11204d01e9b5697faf49b242d7fb0b65b810bb11 100644 --- a/src/styles/legacy_style.css +++ b/src/styles/legacy_style.css @@ -1,25 +1,4 @@ -.playpreviewbtn { - text-decoration: none; - width: 100%; - left: 0px; - color: lightgrey; - position: absolute; - font-size: 3em; - text-align: center; - line-height: 130px; - text-shadow: 0 0 2px black; -} - -.thumbnailimg { - height: 130px; - position: relative; - margin-bottom: 10px; - background-size: contain; - background-repeat: no-repeat; - background-position: 50% 50%; -} - @media (min-width: 767px) { .list-group-item-condensed { padding-top: 2px !important; padding-bottom: 2px !important; } } diff --git a/src/videoag/authentication/ViewPermissions.tsx b/src/videoag/authentication/ViewPermissions.tsx index 5d23d6faf5eb07205b4fe2be9881d5c1e9a70cda..fa574e6df39223d3213a738176410035be6000c0 100644 --- a/src/videoag/authentication/ViewPermissions.tsx +++ b/src/videoag/authentication/ViewPermissions.tsx @@ -11,7 +11,7 @@ export function AuthenticationMethodIcons({ ); } return ( - <div> + <span className="d-flex justify-content-end flex-wrap"> {authentication_methods.map((method) => { let icon = undefined; let description = ""; @@ -58,6 +58,6 @@ export function AuthenticationMethodIcons({ </span> ); })} - </div> + </span> ); } diff --git a/src/videoag/course/CourseListing.tsx b/src/videoag/course/CourseListing.tsx index d7f7e0f598cef584fa2124f86beb7e92d4610b5e..95ab384c08e7f98220b36f1cd20d4bd0c4d0cdbc 100644 --- a/src/videoag/course/CourseListing.tsx +++ b/src/videoag/course/CourseListing.tsx @@ -26,8 +26,8 @@ function ListingHeader({ course }: { course: GetCourseResponse }) { const { language } = useLanguage(); return ( - <div className="card mb-3"> - <div className={`card-body ${course.visible === false ? "bg-danger-subtle" : ""}`}> + <div className={`card mb-3 ${course.visible === false ? "bg-danger-subtle" : ""}`}> + <div className="card-body"> <h5 className="card-title d-flex"> <span className="panel-title flex-fill"> <EmbeddedOMFieldComponent diff --git a/src/videoag/course/DownloadManager.tsx b/src/videoag/course/DownloadManager.tsx index 6968aa7d4112b74daff316a25041bdcf0a94cf6b..bc6a8d2c785a3b1dd6fdcb9e2584e989feb80d99 100644 --- a/src/videoag/course/DownloadManager.tsx +++ b/src/videoag/course/DownloadManager.tsx @@ -2,572 +2,517 @@ import { useEffect, useRef, useState } from "react"; import Modal from "react-bootstrap/Modal"; import DropdownButton from "react-bootstrap/DropdownButton"; import DropdownItem from "react-bootstrap/DropdownItem"; -import { sortSourceNames, sortSources, sourceToName } from "./Player"; -import { showWarningToast } from "@/videoag/error/ErrorDisplay"; -import type { GetCourseResponse, publish_medium } from "@/videoag/api/types"; +import type { course, publish_medium } 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"; interface DownloadStatus { - downloaded: number; - total: number; + downloadedBytes: number; + totalBytes: number; done: boolean; - percent: number; error?: string; } -// TODO rework. Why is there so much duplicate code? More adjustements for new publish_media needed - -async function downloadWithFileSystemAPI( - replaceStatus: (id: number, fn: (old?: DownloadStatus) => DownloadStatus) => void, - course: GetCourseResponse, - chosenLectures: any, - chosenQualities: any, - requestStopDownload: any, -) { - if (!(window as any).showDirectoryPicker) { - throw "unsupported"; - } - const saveDir = await (window as any).showDirectoryPicker(); - if (!saveDir) { - return "cancelled"; - } +//TODO check authorized - await saveDir.requestPermission({ mode: "readwrite" }); +//TODO Test ospf and file system api - for (let lecture of course.lectures!) { - if (chosenLectures.current.get(lecture.id) && lecture.publish_media) { - try { - if (requestStopDownload.current) { - replaceStatus(lecture.id, () => ({ - downloaded: 0, - total: 0, - done: false, - percent: 0, - error: "Download cancelled", - })); - continue; - } - let chosenMedium: publish_medium | undefined = undefined; - for (let medium of lecture.publish_media) { - if (sourceToName(medium) === chosenQualities.current.get(lecture.id)) { - chosenMedium = medium; - break; - } - } - if (!chosenMedium || !chosenMedium.url) continue; - - // TODO file ending mp4. title often empty? - let filename = `videoag_${course.id}_${lecture.id}_${chosenMedium.title}.mp4`; - - let fileHandle = await saveDir.getFileHandle(filename, { create: true }); - let writable = await fileHandle.createWritable(); - let response = await fetch(chosenMedium.url!); - if (response.ok === false) - throw new Error("Download failed, status " + response.status); - let reader = response.body!.getReader(); - let writer = writable.getWriter(); - let lastUpdate = Date.now(); - let downloaded = 0; - let total = chosenMedium.medium_metadata.file_size; - replaceStatus(lecture.id, () => ({ - downloaded, - total, +async function downloadToDirectoryHandle( + replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, + filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + failNowOnException: any, + requestStopDownload: any, + saveDir: any, + onFileDownloaded: (fileHandle: any) => Promise<void>, +): boolean { + for (const toDownload of filesToDownload) { + try { + if (requestStopDownload.current) { + replaceStatus(toDownload.lectureId, () => ({ + downloadedBytes: 0, + totalBytes: 0, done: false, - percent: 0, + error: "Download cancelled", })); - while (true) { - const { done, value } = await reader.read(); - if (done || requestStopDownload.current) { - break; - } - await writer.write(value); - downloaded += value!.byteLength; - let now = Date.now(); - // after 0.2s, update progress - if (now - lastUpdate > 200) { - replaceStatus(lecture.id, () => ({ - downloaded, - total, - done: false, - percent: (downloaded / total) * 100, - })); - lastUpdate = now; - } + continue; + } + let fileHandle = await saveDir.getFileHandle(toDownload.filename, { create: true }); + let writable = await fileHandle.createWritable(); + let response = await fetch(toDownload.url); + if (response.ok === false) + throw new Error("Download failed, status " + response.status); + let reader = response.body!.getReader(); + let writer = writable.getWriter(); + let lastUpdate = Date.now(); + let downloadedBytes = 0; + let totalBytes = toDownload.fileSize; + replaceStatus(toDownload.lectureId, () => ({ + downloadedBytes, + totalBytes, + done: false, + })); + while (true) { + const { done, value } = await reader.read(); + if (done || requestStopDownload.current) { + break; } - await writer.close(); - if (requestStopDownload.current) { - // remove incomplete file - saveDir.removeEntry(filename); - replaceStatus(lecture.id, (old) => ({ - ...old!, - error: "Download cancelled", - })); - } else { - replaceStatus(lecture.id, () => ({ - downloaded, - total, - done: true, - percent: 100, + await writer.write(value); + downloadedBytes += value!.byteLength; + failNowOnException.current = true; + let now = Date.now(); + // after 0.2s, update progress + if (now - lastUpdate > 200) { + replaceStatus(toDownload.lectureId, () => ({ + downloadedBytes, + totalBytes, + done: false, })); + lastUpdate = now; } - } catch (e: any) { - replaceStatus(lecture.id, (old) => ({ + } + await writer.close(); + if (requestStopDownload.current) { + // remove incomplete file + saveDir.removeEntry(toDownload.filename); + replaceStatus(toDownload.lectureId, (old) => ({ ...old!, - error: e + "", + error: "Download cancelled", + })); + } else { + await onFileDownloaded(fileHandle); + replaceStatus(toDownload.lectureId, () => ({ + downloadedBytes, + totalBytes, + done: true, })); } + } catch (e: any) { + replaceStatus(toDownload.lectureId, (old) => ({ + ...old!, + error: e + "", + })); } } + return true; +} - return "success"; +async function downloadWithFileSystemAPI( + replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, + filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + failNowOnException: any, + requestStopDownload: any, +): boolean { + if (!(window as any).showDirectoryPicker) { + throw new Error("File System API unsupported"); + } + const saveDir = await (window as any).showDirectoryPicker(); + if (!saveDir) { + return; + } + + await saveDir.requestPermission({ mode: "readwrite" }); + + return downloadToDirectoryHandle( + replaceStatus, + filesToDownload, + failNowOnException, + requestStopDownload, + saveDir, + (fileHandle) => undefined, + ) } async function downloadWithOPFSAPI( - replaceStatus: (id: number, fn: (old?: DownloadStatus) => DownloadStatus) => void, - course: GetCourseResponse, - chosenLectures: any, - chosenQualities: any, + replaceStatus: (lecture_id: int, fn: (old?: DownloadStatus) => DownloadStatus) => void, + filesToDownload: { lectureId: int, url: string, filename: string, fileSize: int }[], + failNowOnException: any, requestStopDownload: any, -) { +): boolean { if (!navigator.storage || !navigator.storage.getDirectory) { - throw "unsupported"; + throw new Error("OPFS Api unsupported"); } let saveDir = await navigator.storage.getDirectory(); if (!saveDir) { - return "cancelled"; + return; } // clear the directory saveDir.removeEntry("videoag", { recursive: true }); saveDir = await saveDir.getDirectoryHandle("videoag", { create: true }); if (!saveDir) { - return "cancelled"; + return; } - for (let lecture of course.lectures!) { - if (chosenLectures.current.get(lecture.id) && lecture.publish_media) { - try { - if (requestStopDownload.current) { - replaceStatus(lecture.id, () => ({ - downloaded: 0, - total: 0, - done: false, - percent: 0, - error: "Download cancelled", - })); - continue; - } - let chosenMedium: publish_medium | undefined = undefined; - for (let medium of lecture.publish_media) { - if (sourceToName(medium) === chosenQualities.current.get(lecture.id)) { - chosenMedium = medium; - break; - } - } - if (!chosenMedium || !chosenMedium.url) continue; - - // TODO file ending mp4. title often empty? - let filename = `videoag_${course.id}_${lecture.id}_${chosenMedium.title}.mp4`; - - let fileHandle = await saveDir.getFileHandle(filename, { create: true }); - let writable = await fileHandle.createWritable(); - let response = await fetch(chosenMedium.url!); - if (response.ok === false) - throw new Error("Download failed, status " + response.status); - let reader = response.body!.getReader(); - let writer = writable.getWriter(); - let lastUpdate = Date.now(); - let downloaded = 0; - let total = chosenMedium.medium_metadata.file_size; - replaceStatus(lecture.id, () => ({ - downloaded, - total, + return downloadToDirectoryHandle( + replaceStatus, + filesToDownload, + failNowOnException, + requestStopDownload, + saveDir, + async (fileHandle) => { + // create a element to download the file + let a = document.createElement("a"); + a.href = URL.createObjectURL(await fileHandle.getFile()); + a.download = filename; + 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 }[], + 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."); + for (const toDownload of filesToDownload) { + try { + if (requestStopDownload.current) { + replaceStatus(toDownload.lectureId, () => ({ + downloadedBytes: 0, + totalBytes: 0, done: false, - percent: 0, - })); - while (true) { - const { done, value } = await reader.read(); - if (done || requestStopDownload.current) { - break; - } - await writer.write(value); - downloaded += value!.byteLength; - let now = Date.now(); - // after 0.2s, update progress - if (now - lastUpdate > 200) { - replaceStatus(lecture.id, () => ({ - downloaded, - total, - done: false, - percent: (downloaded / total) * 100, - })); - lastUpdate = now; - } - } - await writer.close(); - if (requestStopDownload.current) { - // remove incomplete file - saveDir.removeEntry(filename); - replaceStatus(lecture.id, (old) => ({ - ...old!, - error: "Download cancelled", - })); - } else { - // create a element to download the file - let a = document.createElement("a"); - a.href = URL.createObjectURL(await fileHandle.getFile()); - a.download = filename; - a.target = "_blank"; - a.click(); - - replaceStatus(lecture.id, () => ({ - downloaded, - total, - done: true, - percent: 100, - })); - } - } catch (e: any) { - replaceStatus(lecture.id, (old) => ({ - ...old!, - error: e + "", + error: "Download cancelled", })); + continue; } + let a = document.createElement("a"); + a.href = toDownload.url; + a.download = toDownload.filename; + a.target = "_blank"; + a.click(); + failNowOnException.current = true; + } catch (e: any) { + replaceStatus(toDownload.lectureId, (old) => ({ + ...old!, + error: e + "", + })); } } - return "success"; + return true; } -async function downloadWithDownloadAPI( - replaceStatus: (id: number, fn: (old?: DownloadStatus) => DownloadStatus) => void, - course: GetCourseResponse, - chosenLectures: any, - chosenQualities: any, +async function downloadMedia( + downloadStatus: Map<int, DownloadStatus> | undefined, + setDownloadStatus: (fn: (old?: Map<int, DownloadStatus> | undefined) => Map<int, DownloadStatus> | undefined) => void, + course: course, + chosenMediumIdByLectureId: Map<int, int>, + failNowOnException: any, requestStopDownload: any, ) { - for (let lecture of course.lectures!) { - if (chosenLectures.current.get(lecture.id) && lecture.publish_media) { - try { - if (requestStopDownload.current) { - replaceStatus(lecture.id, () => ({ - downloaded: 0, - total: 0, - done: false, - percent: 0, - error: "Download cancelled", - })); - continue; - } - let chosenMedium: publish_medium | undefined = undefined; - for (let medium of lecture.publish_media) { - if (sourceToName(medium) === chosenQualities.current.get(lecture.id)) { - chosenMedium = medium; - break; - } - } - if (!chosenMedium || !chosenMedium.url) continue; - - // TODO file ending mp4. title often empty? - let filename = `videoag_${course.id}_${lecture.id}_${chosenMedium.title}.mp4`; - - let a = document.createElement("a"); - a.href = chosenMedium.url!; - a.download = filename; - a.target = "_blank"; - a.click(); - } catch (e: any) { - replaceStatus(lecture.id, (old) => ({ - ...old!, - error: e + "", - })); + const filesToDownload = []; + for (const lecture of course.lectures!) { + const chosenMediumId = chosenMediumIdByLectureId.get(lecture.id); + 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`); + + filesToDownload.push({ + lectureId: lecture.id, + url: medium.url, + // TODO file ending mp4 + // TODO lecture date and not id + filename: `videoag_${course.handle}_${lecture.id}_${getMediumQualityName(medium)}.mp4`, + fileSize: medium.file_size, + }); + } + + setDownloadStatus(new Map()); + const replaceStatus = (lectureId: int, fn: (old?: DownloadStatus) => DownloadStatus) => { + setDownloadStatus((oldStatus) => { + const newStatus = new Map(oldStatus); + newStatus.set(lectureId, fn(oldStatus?.get(lectureId))); + return newStatus; + }); + }; + + console.log("Starting download..."); + failNowOnException.current = false; + const success = await downloadWithFileSystemAPI( + replaceStatus, + filesToDownload, + requestStopDownload, + ).catch((e) => { + console.log("Error with File System Api", e); + if (failNowOnException.current) { + showError(e, "An error occurred while downloading"); + return false; } + return downloadWithOPFSAPI( + replaceStatus, + filesToDownload, + requestStopDownload, + ); + }).catch((e) => { + console.log("Error with OPFS Api", e); + if (failNowOnException.current) { + showError(e, "An error occurred while downloading"); + return false; + } + return downloadWithDownloadAPI( + replaceStatus, + filesToDownload, + requestStopDownload, + ); + }).catch((e) => { + console.log("Error with Download Api", e); + showError(e, "An error occurred while downloading"); + return false; + }); + console.log(`Download finished. Success: ${success}`) + if (success) { + showInfoToast("Download finished", true) + } +} + +function DownloadAllLectureListItem({ + lecture, + selectedMediumId, + setSelectedMediumId, + isDownloading, + downloadStatus, +}: { + lecture: lecture; + selectedMediumId: int | undefined; + setSelectedMediumId: (int) => void; + isDownloading: boolean; + downloadStatus: DownloadStatus | undefined; +}) { + const { language } = useLanguage(); + + const media = getSortedDownloadablePublishMedia(lecture.publish_media!); + + let displayedProgress; + if (isDownloading && selectedMediumId !== undefined) { + // Lecture needs to be downloaded + if (downloadStatus === undefined) { + // Not yet started + displayedProgress = ( + <div className="spinner-border" role="status"> + <span className="visually-hidden">Loading...</span> + </div> + ); + } else if (downloadStatus.error) { + displayedProgress = ( + <span className="text-danger"> + {downloadStatus.get(lecture.id)?.error} + </span> + ); + } else if (downloadStatus.done) { + displayedProgress = <span className="text-success">Done</span>; + } else { + displayedProgress = ( + <> + <div className="progress" style={{ width: "100px" }}> + <div + className="progress-bar" + role="progressbar" + style={{ + width: downloadStatus.percent + "%", + }} + aria-valuenow={downloadStatus.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 + </span> + </> + ); } + } else if (isDownloading) { + // Lecture should not be downloaded + displayedProgress = <></>; + } else { + const selectedMedium = media.find((m) => m.id == selectedMediumId); + displayedProgress = ( + <a href={selectedMedium?.url} download target="_blank"> + <button + type="button" + className="btn btn-primary" + disabled={selectedMedium === undefined} + > + {language.get("ui.download_manager.download_now")} + </button> + </a> + ); } - return "success"; + return ( + <tr> + <td className="col-1"> + <input + type="checkbox" + className="form-check-input" + checked={selectedMediumId !== undefined} + disabled={isDownloading || media.length == 0} + onChange={(e) => setSelectedMediumId(media[0].id)} + /> + </td> + <td + className={ + "col-6 make-span-overflow-scroll " + + selectedMediumId !== undefined ? "" : "text-secondary" + } + > + <StylizedText>{lecture.title}</StylizedText> + </td> + <td className="col-3"> + <select + className="form-select" + disabled={isDownloading || media.length == 0} + value={selectedMediumId} + onChange={(e) => setSelectedMediumId(e.target.value)} + > + {getNamedPublishMedia(media).map(({ name, medium }) => ( + <option key={medium.id} value={medium.id}> + {name} + </option> + ))} + </select> + </td> + <td className="col-2 text-center">{displayedProgress}</td> + </tr> + ); } -export default function DownloadAllModal({ course }: { course: GetCourseResponse }) { +export default function DownloadAllModal({ course }: { course: course }) { const [showModal, setShowModal] = useState(false); - const chosenQualities = useRef(new Map<number, string>()); - const chosenLectures = useRef(new Map<number, boolean>()); - const [forceReload, setForceReload] = useState(0); - const [downloadStarted, setDownloadStarted] = useState(false); + const [chosenMediumIdByLectureId, setChosenMediumIdByLectureId] = useState<Map<int, int>>(new Map()); + const requestStopDownload = useRef(false); - const [downloadStatus, setDownloadStatus] = useState<Map<number, DownloadStatus>>(); + const failNowOnException = useRef(false); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadStatus, setDownloadStatus] = useState<Map<int, DownloadStatus> | undefined>(); const { language } = useLanguage(); const closeClicked = () => { - if (!downloadStarted) setShowModal(false); + if (!isDownloading) setShowModal(false); else showWarningToast("A download is in progress"); }; useEffect(() => { if (!showModal) return; + setIsDownloading(false); setDownloadStatus(undefined); requestStopDownload.current = false; - chosenQualities.current = new Map<number, string>(); - chosenLectures.current = new Map<number, boolean>(); - for (let lecture of course.lectures!) { - let sortedsources = sortSources(lecture.publish_media).filter((q) => q.url); - chosenLectures.current.set(lecture.id, sortedsources.length > 0); - if (sortedsources.length > 0) - chosenQualities.current.set(lecture.id, sourceToName(sortedsources[0])); + 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); } - setForceReload(forceReload + 1); + setChosenMediumIdByLectureId(newChosenMedia); // eslint-disable-next-line react-hooks/exhaustive-deps }, [showModal, course.lectures]); - if ( - !course.lectures || - !course.lectures.some( - (lecture) => - lecture.publish_media && lecture.publish_media.some((ele) => ele.allow_download), - ) - ) { - return <></>; - } - - let lectures = []; - let allQualities: string[] = []; + let allQualityNames: string[] = []; let totalSize = 0; - for (let lecture of course.lectures!) { - let sortedsources = sortSources(lecture.publish_media).filter((q) => q.url); - - for (let source of lecture.publish_media ?? []) { - let qName = source.title; - if (qName.length === 0) { - qName = "<empty>"; - } - if (!allQualities.includes(qName)) { - allQualities.push(qName); - } - } + for (const lecture of course.lectures!) { + const media = getSortedDownloadablePublishMedia(lecture.publish_media!); + const selectedMediumId = chosenMediumIdByLectureId.get(lecture.id); - let selectedQuality: publish_medium | undefined = undefined; - if (chosenQualities.current.get(lecture.id)) { - selectedQuality = lecture.publish_media?.find( - (source) => sourceToName(source) === chosenQualities.current.get(lecture.id), - ); - } - if (selectedQuality && chosenLectures.current.get(lecture.id)) { - totalSize += Math.max(0, selectedQuality.medium_metadata.file_size); - } + for (const medium of media) { + const qualityName = getMediumQualityName(medium); + if (!allQualityNames.includes(qualityName)) + allQualityNames.push(qualityName); - let displayedProgress; - if (downloadStatus !== undefined) { - if (chosenLectures.current.get(lecture.id) === true) { - // Lecture needs to be downloaded - if (downloadStatus.get(lecture.id) === undefined) { - // Not yet started - displayedProgress = ( - <div className="spinner-border" role="status"> - <span className="visually-hidden">Loading...</span> - </div> - ); - } else { - // started - if (downloadStatus.get(lecture.id)?.error) { - displayedProgress = ( - <span className="text-danger"> - {downloadStatus.get(lecture.id)?.error} - </span> - ); - } else if (downloadStatus.get(lecture.id)?.done) { - displayedProgress = <span className="text-success">Done</span>; - } else { - displayedProgress = ( - <> - <div className="progress" style={{ width: "100px" }}> - <div - className="progress-bar" - role="progressbar" - style={{ - width: downloadStatus.get(lecture.id)?.percent + "%", - }} - aria-valuenow={downloadStatus.get(lecture.id)?.percent} - aria-valuemin={0} - aria-valuemax={100} - /> - </div> - <span> - {Math.round( - (downloadStatus.get(lecture.id)?.downloaded ?? 0) / - 1024 / - 1024, - )}{" "} - MB /{" "} - {Math.round( - (downloadStatus.get(lecture.id)?.total ?? 0) / 1024 / 1024, - )}{" "} - MB - </span> - </> - ); - } - } - } else { - // Lecture should not be downloaded - displayedProgress = <></>; - } - } else { - displayedProgress = ( - <a href={selectedQuality?.url} download> - <button - type="button" - className="btn btn-primary" - disabled={chosenQualities.current.get(lecture.id) === undefined} - > - {language.get("ui.download_manager.download_now")} - </button> - </a> - ); + if (medium.id === selectedMediumId) + totalSize += medium.medium_metadata.file_size; } - - lectures.push( - <tr key={lecture.id}> - <td className="col-1"> - <input - type="checkbox" - className="form-check-input" - checked={chosenLectures.current.get(lecture.id) ?? false} - disabled={downloadStarted} - onChange={(e) => { - let nextVal = - e.target.checked && - (lecture.publish_media ?? []).some((q) => q.url); - chosenLectures.current.set(lecture.id, nextVal); - setForceReload(forceReload + 1); - }} - /> - </td> - <td - className={ - "col-6 make-span-overflow-scroll " + - ((chosenLectures.current.get(lecture.id) ?? false) ? "" : "text-secondary") - } - > - <StylizedText>{lecture.title}</StylizedText> - </td> - <td className="col-3"> - <select - className="form-select" - disabled={downloadStarted || sortedsources.length === 0} - value={chosenQualities.current.get(lecture.id)} - onChange={(e) => { - chosenQualities.current.set(lecture.id, e.target.value); - setForceReload(forceReload + 1); - }} - > - {sortedsources.map((source) => { - let sourceName = sourceToName(source); - - return ( - <option key={sourceName} value={sourceName}> - {sourceName} - </option> - ); - })} - </select> - </td> - <td className="col-2 text-center">{displayedProgress}</td> - </tr>, - ); } - allQualities = sortSourceNames(allQualities); + if (allQualityNames.length === 0) { + // No media exists which could be downloaded + return <></>; + } const selectAll = () => { - if (downloadStarted) return; - for (let lecture of course.lectures!) { - if (lecture.publish_media && lecture.publish_media.some((source) => source.url)) { - chosenLectures.current.set(lecture.id, true); - } + if (isDownloading) return; + + 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); } - setForceReload(forceReload + 1); + setChosenMediumIdByLectureId(newChosenMedia); }; const selectNone = () => { - if (downloadStarted) return; - for (let lecture of course.lectures!) { - chosenLectures.current.set(lecture.id, false); + if (isDownloading) return; + setChosenMediumIdByLectureId(new Map()); + }; + const setLectureToMediumId = (lecture_id: int, medium_id: int) => { + if (isDownloading) return; + + const newChosenMedia = new Map(chosenMediumIdByLectureId); + newChosenMedia.set(lecture_id, medium_id); + setChosenMediumIdByLectureId(newChosenMedia); + }; + const setAllToQuality = (qualityToSet: string) => { + if (isDownloading) return; + + 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 (getMediumQualityName(medium) === qualityToSet) + newChosenMedia.set(lecture.id, medium.id); + } } - setForceReload(forceReload + 1); + setChosenMediumIdByLectureId(newChosenMedia); }; const startDownloads = async () => { - let newDownloadStatus = new Map<number, DownloadStatus>(); - setDownloadStatus(newDownloadStatus); - - const replaceStatus = (id: number, fn: (old?: DownloadStatus) => DownloadStatus) => { - setDownloadStatus((oldStatus) => { - let newStatus = new Map(oldStatus); - newStatus.set(id, fn(oldStatus?.get(id))); - return newStatus; - }); - }; - - const success = await downloadWithFileSystemAPI( - replaceStatus, + if (isDownloading) return; + downloadMedia( + downloadStatus, + setDownloadStatus, course, - chosenLectures, - chosenQualities, + chosenMediumIdByLectureId, + failNowOnException, requestStopDownload, - ) - .catch((e) => { - console.log(e); - return downloadWithOPFSAPI( - replaceStatus, - course, - chosenLectures, - chosenQualities, - requestStopDownload, - ); - }) - .catch((e) => { - console.log(e); - return downloadWithDownloadAPI( - replaceStatus, - course, - chosenLectures, - chosenQualities, - requestStopDownload, - ); - }) - .catch((e) => { - console.log(e); - return "error"; - }); - console.log(success); - - setDownloadStarted(false); + ); + setIsDownloading(false); }; const toggleDownload = () => { - if (downloadStarted) { + if (isDownloading) { requestStopDownload.current = true; } else { - setDownloadStarted(true); + setIsDownloading(true); requestStopDownload.current = false; startDownloads(); requestStopDownload.current = false; } }; - const setAllQualities = (q: string) => { - for (let lecture of course.lectures!) { - if (lecture.publish_media) { - let source = lecture.publish_media.find((s) => s.title === q); - if (source) { - chosenQualities.current.set(lecture.id, sourceToName(source)); - } - } - } - setForceReload(forceReload + 1); - }; - return ( <> <button type="button" className="btn btn-secondary" onClick={() => setShowModal(true)}> @@ -584,15 +529,15 @@ export default function DownloadAllModal({ course }: { course: GetCourseResponse type="button" className="btn btn-primary" onClick={toggleDownload} - disabled={downloadStarted && requestStopDownload.current} + disabled={isDownloading && requestStopDownload.current} > <i className={ "bi me-1 " + - (downloadStarted ? "bi-pause-fill" : "bi-play-fill") + (isDownloading ? "bi-pause-fill" : "bi-play-fill") } /> - {downloadStarted + {isDownloading ? language.get("ui.download_manager.stop_download") : language.get("ui.download_manager.start_download")} {totalSize > 0 && ( @@ -605,7 +550,7 @@ export default function DownloadAllModal({ course }: { course: GetCourseResponse type="button" className="btn btn-secondary me-2" onClick={selectAll} - disabled={downloadStarted} + disabled={isDownloading} > {language.get("ui.download_manager.select_all")} </button> @@ -614,7 +559,7 @@ export default function DownloadAllModal({ course }: { course: GetCourseResponse type="button" className="btn btn-secondary me-2" onClick={selectNone} - disabled={downloadStarted} + disabled={isDownloading} > {language.get("ui.download_manager.select_none")} </button> @@ -624,11 +569,11 @@ export default function DownloadAllModal({ course }: { course: GetCourseResponse title={language.get("ui.download_manager.quality")} className="" > - {allQualities.map((q) => ( + {allQualityNames.map((q) => ( <DropdownItem key={q} type="button" - onClick={() => setAllQualities(q)} + onClick={() => setAllToQuality(q)} > {q} </DropdownItem> @@ -640,14 +585,23 @@ export default function DownloadAllModal({ course }: { course: GetCourseResponse className="table table-bordered table-responsive-sm" style={{ tableLayout: "fixed" }} > - <tbody>{lectures}</tbody> + <tbody> + {course.lectures.map((lecture) => ( + <DownloadAllLectureListItem + key={lecture.id} + lecture={lecture} + selectedMediumId={chosenMediumIdByLectureId.get(lecture.id)} + setSelectedMediumId={(medium_id) => setLectureToMediumId(lecture.id, medium_id)} + /> + ))} + </tbody> </table> </Modal.Body> <Modal.Footer> <button className="btn btn-secondary" onClick={closeClicked} - disabled={downloadStarted} + disabled={isDownloading} > {language.get("ui.download_manager.close")} </button> diff --git a/src/videoag/course/Lecture.tsx b/src/videoag/course/Lecture.tsx index fec1f23437bd062fbe0c12cf627caac4812090e4..2f9975e9678523077ba53776ee9d8826d9d512e1 100644 --- a/src/videoag/course/Lecture.tsx +++ b/src/videoag/course/Lecture.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { OverlayTrigger, Tooltip } from "react-bootstrap"; -import type { lecture } from "@/videoag/api/types"; +import type { lecture, course } from "@/videoag/api/types"; import { OMDelete, OMEdit, @@ -9,24 +9,27 @@ import { EmbeddedOMFieldComponent, } from "@/videoag/object_management/OMConfigComponent"; import { AuthenticationMethodIcons } from "@/videoag/authentication/ViewPermissions"; +import { datetimeToString } from "@/videoag/miscellaneous/Formatting"; +import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; -import { DownloadSources, EditSources } from "./Player"; +import { PublishMediumDownloadButton, PublishMediumList } from "./Medium"; +import { LectureLiveLabel } from "./LiveLabel"; export function urlForLecture(course_id: string, lecture_id: string | number) { return `${course_id}/${lecture_id}`; } export function getLectureThumbnailUrl(lecture: lecture): string | undefined { - if (!lecture.is_authenticated) return undefined; - - for (let medium of lecture.publish_media ?? []) { - if (medium.medium_metadata.type === "thumbnail") { - return medium.url; + if (lecture.is_authenticated) { + for (let medium of lecture.publish_media ?? []) { + if (medium.medium_metadata.type === "thumbnail") { + return medium.url; + } } } - return undefined; + return "/static/empty_thumbnail.png"; } export function LectureListItem({ @@ -43,13 +46,7 @@ export function LectureListItem({ const { editMode } = useEditMode(); const { language } = useLanguage(); - const showDownloadSourcesButton = - editMode === false && - lecture.publish_media && - lecture.publish_media.some((ele) => ele.url !== undefined); - const thumbnailUrl = getLectureThumbnailUrl(lecture); - // TODO add link when any viewable medium, even if no thumbnail return ( <li @@ -58,24 +55,24 @@ export function LectureListItem({ {...props} > <div className="row"> - {thumbnailUrl !== undefined ? ( + {lecture.publish_media?.some((m) => m.include_in_player) ? ( <div style={{ backgroundImage: `url('${thumbnailUrl}')`, + height: "8em", }} - className="col-sm-2 col-12 thumbnailimg" + className="col-md-2 col-12 center-background-img position-relative h-5" > <Link href={`/${course_handle}/${lecture.id}`}> - <span - aria-hidden="true" - className={"playpreviewbtn bi bi-play-circle"} - /> + <span className="centered-overlay-container" aria-hidden="true"> + <span className={"bi bi-play-circle fs-1"} style={{ color: "lightgrey" }} /> + </span> </Link> </div> ) : ( - <div className="col-sm-2 col-12"></div> + <div className="col-md-2 col-12"></div> )} - <ul className="list-unstyled col-sm-3 col-12"> + <ul className="list-unstyled col-md-3 col-12"> <li> <EmbeddedOMFieldComponent object_type="lecture" @@ -85,7 +82,7 @@ export function LectureListItem({ initialValue={lecture.title} /> </li> - {lecture.speaker && ( + {(editMode || lecture.speaker) && ( <li> {language.get("ui.generic.lecture_given_by")}{" "} <EmbeddedOMFieldComponent @@ -109,90 +106,108 @@ export function LectureListItem({ initialValue={lecture.time} /> </li> - {lecture.internal_comment && lecture.internal_comment.trim().length > 0 && ( - <li className="text-muted"> + </ul> + <ul className="list-unstyled col-md-3 col-12"> + <li className="d-flex mb-1"> + {editMode && ( <OverlayTrigger overlay={ <Tooltip> - {language.get("object.lecture.internal_comment")} + {language.get("object.lecture.description")} </Tooltip> } > - <span className="bi bi-chat-left-quote" /> + <span className="bi bi-chat-left-quote ms-1 me-1" /> </OverlayTrigger> - + )} + <span style={{ width: 0, flexGrow: 1 }}> <EmbeddedOMFieldComponent object_type="lecture" object_id={lecture.id!} - field_id="internal_comment" + field_id="description" field_type="string" - initialValue={lecture.internal_comment} + initialValue={lecture.description} allowMarkdown={true} /> - </li> - )} - </ul> - <ul className="list-unstyled col-sm-3 col-12"> - <li> - <EmbeddedOMFieldComponent - object_type="lecture" - object_id={lecture.id!} - field_id="description" - field_type="string" - initialValue={lecture.description} - allowMarkdown={true} - /> + </span> </li> - {show_chapters && - lecture.chapters?.map((chapter) => ( - <li - key={chapter.name} - className={ - "d-flex " + - (chapter.visible === false ? "bg-danger-subtle rounded" : "") + {(editMode || lecture.internal_comment) && ( + <li className="text-muted bg-danger-subtle d-flex mb-1" style={{ borderRadius: "0.3em" }}> + <OverlayTrigger + overlay={ + <Tooltip> + {language.get("object.lecture.internal_comment")} + </Tooltip> } > - <span className="bi bi-play" /> - <Link - href={`/${course_handle}/${lecture.id}?t=` + chapter.start_time} - > - {chapter.name} - </Link> - {editMode && ( - <> - <div className="flex-fill" /> - <EmbeddedOMFieldComponent - object_type="chapter" - object_id={chapter.id!} - field_id="visible" - field_type="boolean" - initialValue={chapter.visible} - className="me-2 align-self-center py-0" - /> - <OMDelete - object_type="chapter" - object_id={chapter.id!} - className="py-0" - /> - </> - )} - </li> - ))} - </ul> - <ul className="col-sm-4 col-12 list-unstyled d-flex"> - {showDownloadSourcesButton && ( - <DownloadSources - publish_media={lecture.publish_media ?? []} - direction="down" - tabIndex="-1" - /> + <span className="bi bi-chat-left-quote ms-1 me-1" /> + </OverlayTrigger> + <span style={{ width: 0, flexGrow: 1 }}> + <EmbeddedOMFieldComponent + object_type="lecture" + object_id={lecture.id!} + field_id="internal_comment" + field_type="string" + initialValue={lecture.internal_comment} + allowMarkdown={true} + /> + </span> + </li> )} - {editMode && lecture.publish_media && ( - <EditSources publish_media={lecture.publish_media} /> + {show_chapters && lecture.chapters && ( + <> + {lecture.chapters?.map((chapter) => ( + <li + key={chapter.name} + className={ + "d-flex " + + (chapter.visible === false ? "bg-danger-subtle rounded" : "") + } + > + <span className="bi bi-play" /> + <Link + href={`/${course_handle}/${lecture.id}?t=` + chapter.start_time} + > + {chapter.name} + </Link> + {editMode && ( + <> + <div className="flex-fill" /> + <EmbeddedOMFieldComponent + object_type="chapter" + object_id={chapter.id!} + field_id="visible" + field_type="boolean" + initialValue={chapter.visible} + className="me-2 align-self-center py-0" + /> + <OMDelete + object_type="chapter" + object_id={chapter.id!} + className="py-0" + /> + </> + )} + </li> + ))} + <li className="mb-1" /> + </> )} - - <li className="flex-fill mx-1" /> - <li className="p-1"> + </ul> + <ul className="list-unstyled col-md-4 col-12 pe-0 row align-content-start"> + <li className="col-xl-7 col-12 p-0"> + {editMode ? ( + <PublishMediumList publish_media={lecture.publish_media} /> + ) : ( + <PublishMediumDownloadButton + publish_media={lecture.publish_media ?? []} + direction="down" + tabIndex="-1" + className="pl-1" + /> + )} + </li> + <li className="col-xl-5 col-12 p-0 mt-1 d-flex flex-wrap justify-content-end align-content-start align-items-center"> {editMode && ( <> <EmbeddedOMFieldComponent @@ -224,3 +239,102 @@ export function LectureListItem({ </li> ); } + +export function LectureCard({ + lecture, + course, + size, +}: { + lecture: lecture; + course: course; + size?: "small" | "auto"; +}) { + const { language } = useLanguage(); + let dateStr = datetimeToString(lecture.time); + + let sz = size === "small" ? "xxl" : "sm"; + + return ( + <Link + href={urlForLecture(course.handle, lecture.id)} + title={course.full_name} + className="disable-highlight" + > + {/* display width >= sz */} + <div className={`d-none d-${sz}-block`}> + <div className="row"> + <div className="col-4 position-relative" style={{ + maxHeight: "6em", + maxWidth: "11em", + }}> + <img + style={{ + height: "100%", + width: "100%", + objectFit: "contain", + }} + src={getLectureThumbnailUrl(lecture)} + alt="Vorschaubild" + /> + <span className="centered-overlay-container" aria-hidden="true"> + <span className={"bi bi-play-circle fs-3"} style={{ color: "lightgrey" }} /> + </span> + </div> + <div className="col-5 p-0"> + <span> + <strong>{course.short_name}</strong>{" "} + <LectureLiveLabel lecture={lecture} /> + </span>{" "} + <br /> + <br /> + <span>{dateStr}</span> + {lecture.speaker ? ( + <div className="small p-children-inline"> + {language.get("ui.generic.lecture_given_by")}{" "} + <StylizedText markdown>{lecture.speaker}</StylizedText> + </div> + ) : null} + </div> + <div className="col-3"> + <div> + <StylizedText markdown>{lecture.title}</StylizedText> + </div> + </div> + </div> + </div> + {/* display width < sz */} + <div className={`d-block d-${sz}-none`}> + <ul className="list-unstyled"> + <li className="justify-content-center d-flex mb-1 position-relative"> + <img + style={{ + maxHeight: "6em", + maxWidth: "11em", + objectFit: "contain", + }} + src={getLectureThumbnailUrl(lecture)} + alt="Vorschaubild" + /> + <span className="centered-overlay-container" aria-hidden="true"> + <span className={"bi bi-play-circle fs-3"} style={{ color: "lightgrey" }} /> + </span> + </li> + <li> + <strong>{course.full_name}</strong> + <LectureLiveLabel lecture={lecture} /> + </li> + <li>{dateStr}</li> + {lecture.speaker ? ( + <div className="small p-children-inline"> + {language.get("ui.generic.lecture_given_by")}{" "} + <StylizedText markdown>{lecture.speaker}</StylizedText> + </div> + ) : null} + <li className="disable-last-paragraph-spacing"> + <StylizedText markdown>{lecture.title}</StylizedText> + </li> + </ul> + </div> + </Link> + ); +} diff --git a/src/videoag/course/Medium.tsx b/src/videoag/course/Medium.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e61da73ce754e29b667e99e362c41b27262b41be --- /dev/null +++ b/src/videoag/course/Medium.tsx @@ -0,0 +1,180 @@ +import { Dropdown } from "react-bootstrap"; + +import { + OMDelete, + OMEdit, + OMHistory, + EmbeddedOMFieldComponent, +} from "@/videoag/object_management/OMConfigComponent"; +import { useEditMode } from "@/videoag/object_management/EditModeProvider"; +import { useLanguage } from "@/videoag/localization/LanguageProvider"; +import { filesizeToHuman } from "@/videoag/miscellaneous/Formatting"; + +export function getMediumQualityName(medium: publish_medium): string | undefined { + switch(medium.medium_metadata.type) { + case "plain_video": + return `${medium.medium_metadata.vertical_resolution}p`; + case "plain_audio": + return `${medium.medium_metadata.audio_sample_rate} Hz`; + default: + return undefined; + } +} + +export interface NamedPublishMedium { + name: string; + 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 qualityName = getMediumQualityName(medium); + if (qualityName) + nameParts.push(qualityName); + + nameParts.push(`(${filesizeToHuman(medium.medium_metadata.file_size)})`); + + namedMedia.push({ + name: nameParts.join(" "), + medium, + }); + } + + return namedMedia; +} + +export function sortPublishMediaByQuality(media: publish_medium[]): publish_medium[] { + media.sort((a, b) => { + // TODO one could compare more values here + + if (a.medium_metadata.type === "plain_video") { + if (b.medium_metadata.type !== "plain_video") { + return -1; // a before b + } + + return b.medium_metadata.vertical_resolution! - a.medium_metadata.vertical_resolution!; + } + if (b.medium_metadata.type === "plain_video") { + return 1; // b before a + } + // Both are audio only + return b.medium_metadata.audio_sample_rate! - a.medium_metadata.audio_sample_rate!; + }); + return media; +} + +export function getSortedDownloadablePublishMedia(media: publish_medium[]): publish_medium[] { + return sortPublishMediaByQuality(media.filter((m) => m.allow_download)); +} + +export function PublishMediumList({ publish_media }: { publish_media: publish_medium[] }) { + const { editMode } = useEditMode(); + + 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> + ))} + </ul> + ); +} + +export function PublishMediumDownloadButton({ + publish_media, + className, + direction, + ...props +}: { + publish_media: publish_medium[]; + className?: string; + direction: "up" | "down"; + [key: string]: any; +}) { + const { editMode } = useEditMode(); + const { language } = useLanguage(); + + const namedMedia = getNamedPublishMedia(getSortedDownloadablePublishMedia(publish_media)); + 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> + ); +} \ No newline at end of file diff --git a/src/videoag/course/Player.tsx b/src/videoag/course/Player.tsx index 553cbf2a9a071c9ce9a82fd6740da30e73e53bfe..a48e8a34b4784aef09257da151b2bafea816d2f0 100644 --- a/src/videoag/course/Player.tsx +++ b/src/videoag/course/Player.tsx @@ -41,8 +41,7 @@ import { } from "@/videoag/object_management/OMConfigComponent"; import { basePath } from "@/../basepath"; -import { VideoCard } from "./VideoCard"; -import { urlForLecture, getLectureThumbnailUrl } from "./Lecture"; +import { LectureCard, urlForLecture, getLectureThumbnailUrl } from "./Lecture"; import { PlayerData } from "@/pages/dynamic_player"; @@ -82,28 +81,13 @@ function initPlayer( player_media.filter((medium) => ["plain_video", "plain_audio"].includes(medium.medium_metadata.type), ); - player_media.sort((a, b) => { - // TODO one could compare more values here + sortPublishMediaByQuality(player_media); - if (a.medium_metadata.type === "plain_video") { - if (b.medium_metadata.type !== "plain_video") { - return -1; // a before b - } - - return b.medium_metadata.vertical_resolution! - a.medium_metadata.vertical_resolution!; - } - if (b.medium_metadata.type === "plain_video") { - return 1; // b before a - } - // Both are audio only - return b.medium_metadata.audio_sample_rate! - a.medium_metadata.audio_sample_rate!; - }); - - const sources = player_media.map((medium) => { + const sources = getNamedPublishMedia(player_media).map(({ name, medium }) => { return { src: medium.url, type: medium.medium_metadata.type === "plain_video" ? "video/mp4" : "audio/mpeg", - label: medium.title, + label: name, selected: false, }; }); @@ -652,7 +636,7 @@ function VideoSuggestions({ course, lecture }: { course: course; lecture: lectur {language.get("ui.video_player.previous_lecture")} </Link> <div className="card-body"> - <VideoCard course={course} lecture={prevLecture} size="small" /> + <LectureCard course={course} lecture={prevLecture} size="small" /> </div> {prevLecture.visible === false && ( <div className="card-footer text-center text-muted"> @@ -673,7 +657,7 @@ function VideoSuggestions({ course, lecture }: { course: course; lecture: lectur <i className="bi bi-arrow-right-circle ms-2" /> </Link> <div className="card-body"> - <VideoCard course={course} lecture={nextLecture} size="small" /> + <LectureCard course={course} lecture={nextLecture} size="small" /> </div> {nextLecture.visible === false && ( <div className="card-footer text-center text-muted"> @@ -688,139 +672,6 @@ function VideoSuggestions({ course, lecture }: { course: course; lecture: lectur // TODO move and rework -export function sourceToName(source: publish_medium) { - // TODO: implement this - //if (source.comment.length == 0) - return `${source.title} (${filesizeToHuman(source.medium_metadata.file_size)})`; - - //return `${source.quality.name}, ${source.comment} (${filesizeToHuman(source.size)})`; -} - -export function sortSources(sources?: publish_medium[]) { - return Array.from(sources ?? []).sort((a, b) => { - let aq = parseInt(a.title.split("p")[0]); - let bq = parseInt(b.title.split("p")[0]); - if (aq === bq) { - return (a.id ?? 0) - (b.id ?? 0); - } - return bq - aq; - }); -} - -export function sortSourceNames(sourceNames?: string[]) { - return Array.from(sourceNames ?? []).sort((a, b) => { - let aq = parseInt(a.split("p")[0]); - let bq = parseInt(b.split("p")[0]); - if (aq === bq) { - return a.localeCompare(b); - } - return bq - aq; - }); -} - -export function EditSources({ publish_media }: { publish_media: publish_medium[] }) { - const { editMode } = useEditMode(); - - let sortedsources = sortSources(publish_media); - - return ( - <ul> - {sortedsources.map((source) => ( - <li - key={source.id} - className={ - "d-flex border align-items-center px-2 py-1 rounded " + - (source.visible === false ? "bg-danger-subtle" : "") - } - > - <span className="bi bi-download" /> - <a href={source.url} className="mx-1"> - {sourceToName(source)} - </a> - {editMode && ( - <> - <div className="flex-fill" /> - <EmbeddedOMFieldComponent - object_type="publish_medium" - object_id={source.id!} - field_id="visible" - field_type="boolean" - initialValue={source.visible} - className="me-2 align-self-center py-0" - /> - <OMDelete - object_type="publish_medium" - object_id={source.id!} - className="py-0" - /> - </> - )} - </li> - ))} - </ul> - ); -} - -export function DownloadSources({ - publish_media, - className, - direction, - ...props -}: { - publish_media: publish_medium[]; - className?: string; - direction: "up" | "down"; - [key: string]: any; -}) { - const { editMode } = useEditMode(); - const { language } = useLanguage(); - - let sortedsources = sortSources(publish_media); - - 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> - {sortedsources.map((v, ind) => { - if (v.url === undefined) return null; - let bgColor = ""; - if (v.visible === false) bgColor = "bg-danger-subtle"; - - return ( - <div - key={ind + "#" + v.title} - className={"d-flex align-items-center " + bgColor} - > - <Dropdown.Item href={v.url} download> - {sourceToName(v)} - </Dropdown.Item> - {editMode && ( - <> - <EmbeddedOMFieldComponent - object_type="publish_medium" - object_id={v.id!} - field_id="visible" - field_type="boolean" - initialValue={v.visible} - className="me-2" - /> - <OMDelete object_type="publish_medium" object_id={v.id!} /> - </> - )} - </div> - ); - })} - </Dropdown.Menu> - </Dropdown> - ); -} export function EmbeddedPlayer({ playerData, @@ -1017,7 +868,7 @@ export default function Player({ {lecture.publish_media && lecture.publish_media.some((ele) => ele.url !== undefined) && ( <li className="list-inline-item"> - <DownloadSources + <DownloadMedia className="list-inline-item" direction="up" publish_media={lecture.publish_media ?? []} diff --git a/src/videoag/course/VideoCard.tsx b/src/videoag/course/VideoCard.tsx deleted file mode 100644 index 744c5a353e1f39ad19a527de67b2db0c3e026ee4..0000000000000000000000000000000000000000 --- a/src/videoag/course/VideoCard.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import Link from "next/link"; - -import type { lecture, course } from "@/videoag/api/types"; -import { useApi } from "@/videoag/api/ApiProvider"; -import { datetimeToString } from "@/videoag/miscellaneous/Formatting"; -import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; -import { useLanguage } from "@/videoag/localization/LanguageProvider"; - -import { urlForLecture, getLectureThumbnailUrl } from "./Lecture"; -import { LectureLiveLabel } from "./LiveLabel"; - -export function VideoCard({ - lecture, - course, - size, -}: { - lecture: lecture; - course: course; - size?: "small" | "auto"; -}) { - const api = useApi(); - const { language } = useLanguage(); - let dateStr = datetimeToString(lecture.time); - - let sz = size === "small" ? "xxl" : "sm"; - - const thumbnailUrl = getLectureThumbnailUrl(lecture); - - return ( - <Link - href={urlForLecture(course.handle, lecture.id)} - title={course.full_name} - className="disable-highlight" - > - {/* display width >= sz */} - <div className={`d-none d-${sz}-block`}> - <div className="row"> - <img - className="col-4" - width="170" - height="100" - style={{ - maxHeight: "120px", - height: "auto", - width: "170px", - }} - src={thumbnailUrl} - alt="Vorschaubild" - /> - <div className="col-4"> - <span> - <strong>{course.short_name}</strong>{" "} - <LectureLiveLabel lecture={lecture} /> - </span>{" "} - <br /> - <br /> - <span>{dateStr}</span> - {lecture.speaker ? ( - <div className="small p-children-inline"> - {language.get("ui.generic.lecture_given_by")}{" "} - <StylizedText markdown>{lecture.speaker}</StylizedText> - </div> - ) : null} - </div> - <div className="col-4"> - <div> - <StylizedText markdown>{lecture.title}</StylizedText> - </div> - </div> - </div> - </div> - {/* display width < sz */} - <div className={`d-block d-${sz}-none`}> - <ul className="list-unstyled"> - <li className="d-flex justify-content-center mb-1"> - <img width="170" height="100" src={thumbnailUrl} alt="Vorschaubild" /> - </li> - <li> - <strong>{course.full_name}</strong> - <LectureLiveLabel lecture={lecture} /> - </li> - <li>{dateStr}</li> - {lecture.speaker ? ( - <div className="small p-children-inline"> - {language.get("ui.generic.lecture_given_by")}{" "} - <StylizedText markdown>{lecture.speaker}</StylizedText> - </div> - ) : null} - <li className="disable-last-paragraph-spacing"> - <StylizedText markdown>{lecture.title}</StylizedText> - </li> - </ul> - </div> - </Link> - ); -}