diff --git a/lang/de.slf b/lang/de.slf index 64d863139631399bfaadc9c11d38ceebe5e95dc2..b7d065e397c4b922b939f5dfdab55bf3caa1a2ba 100644 --- a/lang/de.slf +++ b/lang/de.slf @@ -190,6 +190,8 @@ ui.video_player.login_moodle.description = "Für Teilnehmer der Veranstaltung ve ui.video_player.login_moodle.not_in_course = "Du bist kein Teilnehmer des Moodle-Kurses!" ui.video_player.login_moodle.refresh_course = "Kurse aktualisieren" ui.video_player.student_council_only = "Nur für Fachschaftler verfügbar." +ui.video_player.next_lecture = "Nächste Vorlesung" +ui.video_player.previous_lecture = "Vorherige Vorlesung" ui.feedback.title = "Feedback" ui.feedback.sent_successfully = "Nachricht wurde erfolgreich gesendet. Vielen Dank!" diff --git a/lang/en.slf b/lang/en.slf index 956c5f4c7520446e1e765a60cedf143d5662f956..1a5e0fc3280740c7b0cb3c138e6e6d9fd1d23900 100644 --- a/lang/en.slf +++ b/lang/en.slf @@ -194,6 +194,8 @@ ui.video_player.login_moodle.description = "Available to participants of the Moo ui.video_player.login_moodle.not_in_course = "You are not enrolled in the Moodle course" ui.video_player.login_moodle.refresh_course = "Refresh course" ui.video_player.student_council_only = "Only available to student council members" +ui.video_player.next_lecture = "Next lecture" +ui.video_player.previous_lecture = "Previous lecture" ui.feedback.title = "Feedback" ui.feedback.sent_successfully = "Message was sent successfully. Thank you!" diff --git a/src/components/Player.tsx b/src/components/Player.tsx index c587856323ded2f4c0af5f5cf5619da729da028b..38f9dd5e224ab977ae41536dc0d5de9b384ba4f5 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -16,7 +16,9 @@ import Title from "./TitleComponent"; import { showError } from "@/misc/ErrorHandlers"; import type React from "react"; -import { +import VideoJsPlayerType from "video.js/dist/types/player"; +import type { + course, GetCourseResponse, authentication_method, authentication_methods, @@ -31,6 +33,67 @@ import { useLanguage } from "./LanguageProvider"; import { StylizedText } from "./StylizedText"; import { ApiError } from "@/api/ApiError"; import Head from "next/head"; +import { VideoCard } from "./VideoCard"; +import { urlForLecture } from "@/misc/Util"; +import VideoJsMarkers from "@/misc/videojs-markers"; + +function initPlayer( + player: VideoJsPlayerType, + media_sources: media_source[], + chapters?: chapter[], +) { + if (!player.getChild("ControlBar")?.getChild("QualitySelector")) { + player + .getChild("ControlBar")! + .addChild("QualitySelector", {}, player.getChild("ControlBar")!.children().length - 1); + } + let sources = media_sources.map((source) => { + return { + src: source.player_url, + type: "video/mp4", + label: source.quality.name, + selected: false, + __quality: source.quality, + }; + }); + if (sources.length > 0) { + // find highest priority source + let bestSource = sources.reduce((prev, curr) => { + if (curr.__quality.priority > prev.__quality.priority) return curr; + + return prev; + }); + bestSource.selected = true; + } + // sort sources by quality, highest first + sources.sort((a, b) => { + let aq = parseInt(a.label.split("p")[0]); + let bq = parseInt(b.label.split("p")[0]); + return bq - aq; + }); + + player.src(sources); + + let chapterMarkers = + chapters + ?.filter((a) => a.is_visible === undefined || a.is_visible === true) + .sort((a, b) => a.start_time - b.start_time) ?? []; + let markers = []; + for (let i = 0; i < chapterMarkers!.length; i++) { + let c = chapterMarkers![i]; + + markers.push({ + time: c.start_time, + text: c.name, + duration: + i < chapterMarkers?.length - 1 + ? chapterMarkers[i + 1].start_time - c.start_time + : -1, + }); + } + + (player as any).markers.reset(markers); +} function VideoPlayer({ lecture, className }: { lecture: lecture; className?: string }) { const videoRef = useRef<HTMLVideoElement>(null); @@ -40,9 +103,6 @@ function VideoPlayer({ lecture, className }: { lecture: lecture; className?: str .then(() => import("video.js")) .then(async (videojs) => { (await import("@silvermine/videojs-quality-selector")).default(videojs.default); - const videojsMarkers = (await import("@/misc/videojs-markers.js")).default( - videojs.default, - ); const lang = (navigator && @@ -53,80 +113,33 @@ function VideoPlayer({ lecture, className }: { lecture: lecture; className?: str return; } - const player = videojs.default(videoRef.current!, { - language: lang, - userActions: { hotkeys: false }, - playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 3, 3.5, 4], - controlBar: { - pictureInPictureToggle: false, - }, - plugins: { - hotkeys: { - seekStep: 15, - enableVolumeScroll: false, - alwaysCaptureHotkeys: true, - }, - }, - enableSmoothSeeking: true, - inactivityTimeout: 1000, - }); - - if (!player.getChild("ControlBar")?.getChild("QualitySelector")) { - player - .getChild("ControlBar")! - .addChild( - "QualitySelector", - {}, - player.getChild("ControlBar")!.children().length - 1, - ); - videojsMarkers(player); + const videojsMarkers = VideoJsMarkers(videojs.default); - let sources = lecture.media_sources!.map((source) => { - return { - src: source.player_url, - type: "video/mp4", - label: source.quality.name, - selected: false, - __quality: source.quality, - }; - }); - if (sources.length > 0) { - // find highest priority source - let bestSource = sources.reduce((prev, curr) => { - if (curr.__quality.priority > prev.__quality.priority) return curr; - - return prev; - }); - bestSource.selected = true; - } - // sort sources by quality, highest first - sources.sort((a, b) => { - let aq = parseInt(a.label.split("p")[0]); - let bq = parseInt(b.label.split("p")[0]); - return bq - aq; + const player = + videojs.default.getPlayer(videoRef.current!) ?? + videojs.default(videoRef.current!, { + language: lang, + userActions: { hotkeys: false }, + playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 3, 3.5, 4], + controlBar: { + pictureInPictureToggle: false, + }, + plugins: { + hotkeys: { + seekStep: 15, + enableVolumeScroll: false, + alwaysCaptureHotkeys: true, + }, + }, + enableSmoothSeeking: true, + inactivityTimeout: 1000, }); - player.src(sources); - - let chapterMarkers = - lecture.chapters - ?.filter((a) => a.is_visible === undefined || a.is_visible === true) - .sort((a, b) => a.start_time - b.start_time) ?? []; - let markers = []; - for (let i = 0; i < chapterMarkers!.length; i++) { - let c = chapterMarkers![i]; - - markers.push({ - time: c.start_time, - text: c.name, - duration: - i < chapterMarkers?.length - 1 - ? chapterMarkers[i + 1].start_time - c.start_time - : -1, - }); - } - - // super nice hack to remove warning + if ( + videojs.default.getPlugin("markers") === undefined || + !Object.hasOwn(player, "markers") + ) { + videojsMarkers(player); (player as any).markers({ markerStyle: { width: "8px", @@ -142,9 +155,10 @@ function VideoPlayer({ lecture, className }: { lecture: lecture; className?: str return marker.text; }, }, - markers, }); } + + initPlayer(player, lecture.media_sources!, lecture.chapters); }); }, [lecture.media_sources, lecture.chapters]); @@ -517,6 +531,48 @@ function Chapters({ chapters, seekTo }: { chapters?: chapter[]; seekTo: (time: n ); } +function VideoSuggestions({ course, lecture }: { course: course; lecture: lecture }) { + const { language } = useLanguage(); + // find previous and next lecture + const prevLecture = course.lectures?.findLast((l) => new Date(l.time) < new Date(lecture.time)); + const nextLecture = course.lectures?.find((l) => new Date(l.time) > new Date(lecture.time)); + + return ( + <div className="d-flex w-100 flex-column flex-sm-row"> + {prevLecture && ( + <div className="card"> + <Link + className="card-header text-center bg-light-subtle" + href={urlForLecture(course.id_string, prevLecture.id)} + > + <i className="bi bi-arrow-left-circle me-2" /> + {language.get("ui.video_player.previous_lecture")} + </Link> + <div className="card-body"> + <VideoCard course={course} lecture={prevLecture} size="small" /> + </div> + </div> + )} + + <div className="flex-fill p-2" /> + {nextLecture && ( + <div className="card"> + <Link + className="card-header text-center bg-light-subtle" + href={urlForLecture(course.id_string, nextLecture.id)} + > + {language.get("ui.video_player.next_lecture")} + <i className="bi bi-arrow-right-circle ms-2" /> + </Link> + <div className="card-body"> + <VideoCard course={course} lecture={nextLecture} size="small" /> + </div> + </div> + )} + </div> + ); +} + export function sourceToName(source: media_source) { if (source.comment.length == 0) return `${source.quality.name} (${filesizeToHuman(source.size)})`; @@ -912,7 +968,9 @@ export default function Player({ playerData }: { playerData: ResourceType<Player </Link> </div> </div> - <div className="row">{pageContent}</div> + <div className="row mb-2">{pageContent}</div> + + <VideoSuggestions course={course} lecture={lecture} /> </div> </div> </> diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ccf0901f135bff74b6f8d67a4895e19d910964c --- /dev/null +++ b/src/components/VideoCard.tsx @@ -0,0 +1,92 @@ +import { datetimeToString } from "@/misc/Formatting"; +import { useBackendContext } from "./BackendProvider"; +import Link from "next/link"; +import type { lecture, course } from "@/api/api_v1_types"; +import { StylizedText } from "@/components/StylizedText"; +import { urlForLecture } from "@/misc/Util"; + +export function VideoCard({ + lecture, + course, + size, +}: { + lecture: lecture; + course: course; + size?: "small" | "auto"; +}) { + const api = useBackendContext(); + let dateStr = datetimeToString(lecture.time); + + let sz = size === "small" ? "xxl" : "sm"; + + return ( + <Link + href={urlForLecture(course.id_string, 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={`${api.assetUrl()}/thumbnail/l_${lecture.id}.jpg`} + alt="Vorschaubild" + /> + <div className="col-4"> + <span> + <strong>{course.short_name}</strong>{" "} + {/*{livelabel(0, lecture.livehandle)}*/} + </span>{" "} + <br /> + <br /> + <span>{dateStr}</span> + {lecture.speaker ? ( + <div className="small p-children-inline"> + Gehalten von <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={`${api.assetUrl()}/thumbnail/l_${lecture.id}.jpg`} + alt="Vorschaubild" + /> + </li> + <li> + <strong>{course.full_name}</strong> + {/*{livelabel(0, lecture.livehandle)}*/} + </li> + <li>{dateStr}</li> + {lecture.speaker ? ( + <div className="small p-children-inline"> + Gehalten von <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/misc/Util.tsx b/src/misc/Util.tsx index 1739c86ae9eabba1b64ff9d6b73ae2702064136c..cf7102bd6785c832e6c37e8b7d5c2b2192cf3834 100644 --- a/src/misc/Util.tsx +++ b/src/misc/Util.tsx @@ -132,3 +132,7 @@ export function AuthenticationMethodIcons({ </> ); } + +export function urlForLecture(course_id: string, lecture_id: string | number) { + return `${course_id}/${lecture_id}`; +} diff --git a/src/misc/videojs-markers.js b/src/misc/videojs-markers.js index a657bdd5a5f8058bda2a6329869a9e1cf70d02a6..6970269dbd1c440a21b52a957b34da3e2f859187 100644 --- a/src/misc/videojs-markers.js +++ b/src/misc/videojs-markers.js @@ -60,7 +60,6 @@ export default function videojsMarkers(_video) { }, onMarkerClick: function onMarkerClick(marker) { }, onMarkerReached: function onMarkerReached(marker, index) { }, - markers: [], }; // create a non-colliding random number @@ -484,8 +483,14 @@ export default function videojsMarkers(_video) { } // remove existing markers if already initialized - player.markers.removeAll(); - addMarkers(setting.markers); + if(Object.hasOwn(setting, "markers")){ + player.markers.removeAll(); + addMarkers(setting.markers); + }else{ + // re add to update time + let markers = [...markersList]; + player.markers.reset(markers); + } if (setting.breakOverlay.display) { initializeOverlay(); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 2ba590ae3e6f72f20b53e79c7eef098b994871c6..58776a8e45efe3e142673d523623288978e8106f 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -10,18 +10,16 @@ import { } from "@/components/OMConfigComponent"; import { ReloadBoundary, useReloadBoundary } from "@/components/ReloadBoundary"; import { useUserContext } from "@/components/UserDataProvider"; -import { datetimeToString } from "@/misc/Formatting"; import { ResourceType, fetchDataWrapper } from "@/misc/PromiseHelpers"; -import Link from "next/link"; import React from "react"; import { ErrorPage, showError, showWarningToast } from "@/misc/ErrorHandlers"; import { Suspense, useEffect, useState } from "react"; -import type { GetHomepageResponse, featured, int, lecture, course } from "@/api/api_v1_types"; +import type { GetHomepageResponse, featured, int } from "@/api/api_v1_types"; import { useLanguage } from "@/components/LanguageProvider"; -import { StylizedText } from "@/components/StylizedText"; import { useSearchParams } from "next/navigation"; import { useRouter } from "next/router"; +import { VideoCard } from "@/components/VideoCard"; function FeatureCard({ data, all_featured }: { data: featured; all_featured: featured[] }) { const { editMode } = useEditMode(); @@ -202,85 +200,6 @@ function UpcomingUploads({ homepageData }: { homepageData: ResourceType<GetHomep ); } -function VideoPreview({ lecture, course }: { lecture: lecture; course: course }) { - const api = useBackendContext(); - let dateStr = datetimeToString(lecture.time); - - return ( - <li className="list-group-item"> - <Link - href={`${course.id_string}/${lecture.id}`} - title={course.full_name} - className="disable-highlight" - > - {/* display width >= sm */} - <div className="d-none d-sm-block"> - <div className="row"> - <img - className="col-4" - width="170" - height="100" - style={{ - maxHeight: "120px", - height: "auto", - width: "170px", - }} - src={`${api.assetUrl()}/thumbnail/l_${lecture.id}.jpg`} - alt="Vorschaubild" - /> - <div className="col-4"> - <span> - <strong>{course.short_name}</strong>{" "} - {/*{livelabel(0, lecture.livehandle)}*/} - </span>{" "} - <br /> - <br /> - <span>{dateStr}</span> - {lecture.speaker ? ( - <div className="small p-children-inline"> - Gehalten von{" "} - <StylizedText markdown>{lecture.speaker}</StylizedText> - </div> - ) : null} - </div> - <div className="col-4"> - <div> - <StylizedText markdown>{lecture.title}</StylizedText> - </div> - </div> - </div> - </div> - {/* display width < sm */} - <div className="d-block d-sm-none"> - <ul className="list-unstyled"> - <li className="d-flex justify-content-center mb-1"> - <img - width="170" - height="100" - src={`${api.assetUrl()}/thumbnail/l_${lecture.id}.jpg`} - alt="Vorschaubild" - /> - </li> - <li> - <strong>{course.full_name}</strong> - {/*{livelabel(0, lecture.livehandle)}*/} - </li> - <li>{dateStr}</li> - {lecture.speaker ? ( - <div className="small p-children-inline"> - Gehalten von <StylizedText markdown>{lecture.speaker}</StylizedText> - </div> - ) : null} - <li className="disable-last-paragraph-spacing"> - <StylizedText markdown>{lecture.title}</StylizedText> - </li> - </ul> - </div> - </Link> - </li> - ); -} - function RecentUploads({ homepageData }: { homepageData: ResourceType<GetHomepageResponse> }) { const data = homepageData.read()!; @@ -288,7 +207,11 @@ function RecentUploads({ homepageData }: { homepageData: ResourceType<GetHomepag <ul className="list-group videopreview"> {data.new_lectures.map((lecture, index) => { let course_data = data.courses_context[lecture.course_id]; - return <VideoPreview key={index} lecture={lecture} course={course_data} />; + return ( + <li className="list-group-item" key={index}> + <VideoCard lecture={lecture} course={course_data} />{" "} + </li> + ); })} </ul> );