diff --git a/web/template/partial/stream/video-sections.gohtml b/web/template/partial/stream/video-sections.gohtml
index 6bf289a0e..363c7b032 100644
--- a/web/template/partial/stream/video-sections.gohtml
+++ b/web/template/partial/stream/video-sections.gohtml
@@ -1,127 +1,43 @@
{{define "videosections"}}
-
-
- {{template "videosections-desktop" .ID}}
-
-
- {{template "videosections-mobile" .ID}}
-
-
-{{end}}
-
-{{define "videosections-desktop"}}
-
-
-
-
-
Sections
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{{end}}
-
-{{define "videosections-mobile"}}
-
-
-
Sections
-
-
- Minimize
-
+
-
-
- Show all
-
+
+
-
-
-
+
+
+
+
+
{{end}}
\ No newline at end of file
diff --git a/web/ts/TUMLiveVjs.ts b/web/ts/TUMLiveVjs.ts
index 828c1863f..537a93c19 100644
--- a/web/ts/TUMLiveVjs.ts
+++ b/web/ts/TUMLiveVjs.ts
@@ -1,5 +1,4 @@
import { getQueryParam, keepQuery, postData, Time } from "./global";
-import { VideoSectionList } from "./video-sections";
import { StatusCodes } from "http-status-codes";
import videojs, { VideoJsPlayer } from "video.js";
import airplay from "@silvermine/videojs-airplay";
@@ -675,21 +674,6 @@ export class SeekLogger {
}
}
-export function attachCurrentTimeEvent(videoSection: VideoSectionList) {
- for (let j = 0; j < players.length; j++) {
- players[j].ready(() => {
- let timer;
- (function checkTimestamp() {
- timer = setTimeout(() => {
- highlight(players[j], videoSection);
- checkTimestamp();
- }, 500);
- })();
- players[j].on("seeked", () => highlight(players[j], videoSection));
- });
- }
-}
-
export function switchView(baseUrl: string) {
const isDVR = getQueryParam("dvr") === "";
@@ -703,18 +687,6 @@ export function switchView(baseUrl: string) {
window.location.assign(redirectUrl);
}
-function highlight(player, videoSection) {
- const currentTime = player.currentTime();
- videoSection.currentHighlightIndex = videoSection.list.findIndex((section, i, list) => {
- const next = list[i + 1];
- const sectionSeconds = new Time(section.startHours, section.startMinutes, section.startSeconds).toSeconds();
- return next === undefined || next === null // if last element and no next exists
- ? sectionSeconds <= currentTime
- : sectionSeconds <= currentTime &&
- currentTime <= new Time(next.startHours, next.startMinutes, next.startSeconds).toSeconds() - 1;
- });
-}
-
function debounce(func, timeout) {
let timer;
return (...args) => {
diff --git a/web/ts/api/video-sections.ts b/web/ts/api/video-sections.ts
new file mode 100644
index 000000000..9acb866c5
--- /dev/null
+++ b/web/ts/api/video-sections.ts
@@ -0,0 +1,44 @@
+import { del, get, post, put } from "../utilities/fetch-wrappers";
+
+export class UpdateVideoSectionRequest {
+ Description: string;
+ StartHours: number;
+ StartMinutes: number;
+ StartSeconds: number;
+}
+
+export type Section = {
+ ID?: number;
+ description: string;
+
+ startHours: number;
+ startMinutes: number;
+ startSeconds: number;
+
+ streamID: number;
+ friendlyTimestamp?: string;
+ fileID?: number;
+
+ isCurrent: boolean;
+};
+
+/**
+ * REST API Wrapper for /api/stream/:id/sections
+ */
+export const VideoSectionAPI = {
+ get: async function (streamId: number): Promise {
+ return get(`/api/stream/${streamId}/sections`);
+ },
+
+ add: async function (streamId: number, request: object) {
+ return post(`/api/stream/${streamId}/sections`, request);
+ },
+
+ update: function (streamId: number, id: number, request: UpdateVideoSectionRequest) {
+ return put(`/api/stream/${streamId}/sections/${id}`, request);
+ },
+
+ delete: async function (streamId: number, id: number): Promise {
+ return del(`/api/stream/${streamId}/sections/${id}`);
+ },
+};
diff --git a/web/ts/components/video-sections.ts b/web/ts/components/video-sections.ts
new file mode 100644
index 000000000..fc897d4d1
--- /dev/null
+++ b/web/ts/components/video-sections.ts
@@ -0,0 +1,57 @@
+import { AlpineComponent } from "./alpine-component";
+import { Section } from "../api/video-sections";
+import { DataStore } from "../data-store/data-store";
+import { SlidingWindow } from "../utilities/sliding-window";
+import { registerTimeWatcher } from "../video/watchers";
+import { getPlayers } from "../TUMLiveVjs";
+import { Time } from "../utilities/time";
+import { ToggleableElement } from "../utilities/ToggleableElement";
+
+export function videoSectionContext(streamId: number): AlpineComponent {
+ return {
+ streamId: streamId,
+ autoScroll: new ToggleableElement(),
+ sections: new SlidingWindow([], 6),
+
+ init() {
+ DataStore.videoSections.subscribe(this.streamId, this.updateSection.bind(this));
+ registerTimeWatcher(getPlayers()[0], this.setCurrent.bind(this));
+ },
+
+ nextSection() {
+ this.autoScroll.toggle(false);
+ this.sections.next();
+ },
+
+ prevSection() {
+ this.autoScroll.toggle(false);
+ this.sections.prev();
+ },
+
+ setCurrent(t: number) {
+ this.sections.forEach((s, _) => (s.isCurrent = false));
+ const section = this.sections.find((s, i, arr) => {
+ const next = arr[i + 1];
+ const sectionSeconds = new Time(s.startHours, s.startMinutes, s.startSeconds).toSeconds();
+ return next === undefined || next === null // if last element and no next exists
+ ? sectionSeconds <= t
+ : sectionSeconds <= t &&
+ t <= new Time(next.startHours, next.startMinutes, next.startSeconds).toSeconds() - 1;
+ });
+
+ if (section) {
+ section.isCurrent = true;
+ if (!this.sections.isInWindow(section) && this.autoScroll.value) this.sections.show(section);
+ }
+ },
+
+ isCurrent(i: number) {
+ return i == 0;
+ },
+
+ updateSection(sections: Section[]) {
+ this.sections.set(sections);
+ this.sections.reset();
+ },
+ } as AlpineComponent;
+}
diff --git a/web/ts/data-store/video-sections.ts b/web/ts/data-store/video-sections.ts
index 2ae6837ea..446de9c5a 100644
--- a/web/ts/data-store/video-sections.ts
+++ b/web/ts/data-store/video-sections.ts
@@ -1,9 +1,10 @@
-import { Delete, getData, postData, putData, Section, Time } from "../global";
+import { Time } from "../global";
import { StreamableMapProvider } from "./provider";
+import { Section, UpdateVideoSectionRequest, VideoSectionAPI } from "../api/video-sections";
export class VideoSectionProvider extends StreamableMapProvider {
protected async fetcher(streamId: number): Promise {
- const result = await VideoSections.get(streamId);
+ const result = await VideoSectionAPI.get(streamId);
return result.map((s) => {
s.friendlyTimestamp = new Time(s.startHours, s.startMinutes, s.startSeconds).toString();
return s;
@@ -11,18 +12,18 @@ export class VideoSectionProvider extends StreamableMapProvider {
- await VideoSections.add(streamId, sections);
+ await VideoSectionAPI.add(streamId, sections);
await this.fetch(streamId, true);
await this.triggerUpdate(streamId);
}
async delete(streamId: number, sectionId: number) {
- await VideoSections.delete(streamId, sectionId);
+ await VideoSectionAPI.delete(streamId, sectionId);
this.data[streamId] = (await this.getData(streamId)).filter((s) => s.ID !== sectionId);
}
async update(streamId: number, sectionId: number, request: UpdateVideoSectionRequest) {
- await VideoSections.update(streamId, sectionId, request);
+ await VideoSectionAPI.update(streamId, sectionId, request);
this.data[streamId] = (await this.getData(streamId)).map((s) => {
if (s.ID === sectionId) {
s = {
@@ -43,37 +44,3 @@ export class VideoSectionProvider extends StreamableMapProvider {
- const resp = await getData(`/api/stream/${streamId}/sections`);
- if (!resp.ok) {
- throw Error(resp.statusText);
- }
- return resp.json();
- },
-
- add: async function (streamId: number, request: object) {
- return postData(`/api/stream/${streamId}/sections`, request);
- },
-
- update: function (streamId: number, id: number, request: UpdateVideoSectionRequest) {
- return putData(`/api/stream/${streamId}/sections/${id}`, request);
- },
-
- delete: async function (streamId: number, id: number): Promise {
- return Delete(`/api/stream/${streamId}/sections/${id}`);
- },
-};
diff --git a/web/ts/entry/video.ts b/web/ts/entry/video.ts
index 99c0f2ff5..7d83fb7ff 100644
--- a/web/ts/entry/video.ts
+++ b/web/ts/entry/video.ts
@@ -6,4 +6,5 @@ export * from "../video-sections";
export * from "../splitview";
export * from "../bookmarks";
export * from "../subtitle-search";
+export * from "../components/video-sections";
// Lecture Units are currently not used, so we don't include them in the bundle at the moment
diff --git a/web/ts/global.ts b/web/ts/global.ts
index 7016ad9cb..5fe96316d 100644
--- a/web/ts/global.ts
+++ b/web/ts/global.ts
@@ -3,6 +3,7 @@ import { StatusCodes } from "http-status-codes";
export * from "./notifications";
export * from "./user-settings";
export * from "./start-page";
+export * from "./utilities/time";
export async function getData(url = "") {
return await fetch(url);
@@ -252,57 +253,6 @@ export function keepQuery(url: string): string {
return window.location.search.length > 0 ? url + window.location.search : url;
}
-/**
- * Time Utility Class
- * Conversion of seconds to (hours, minutes, seconds) and vice versa.
- */
-export class Time {
- private readonly hours: number;
- private readonly minutes: number;
- private readonly seconds: number;
-
- static FromSeconds(seconds: number): Time {
- const date = new Date(seconds * 1000);
- return new Time(date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());
- }
-
- constructor(hours = 0, minutes = 0, seconds = 0) {
- this.hours = hours;
- this.minutes = minutes;
- this.seconds = seconds;
- }
-
- public toString() {
- let s = `${Time.padZero(this.minutes)}:${Time.padZero(this.seconds)}`;
- if (this.hours > 0) {
- s = `${Time.padZero(this.hours)}:` + s;
- }
- return s;
- }
-
- public toStringWithLeadingZeros() {
- return `${Time.padZero(this.hours)}:${Time.padZero(this.minutes)}:${Time.padZero(this.seconds)}`;
- }
-
- public toSeconds(): number {
- return this.hours * 60 * 60 + this.minutes * 60 + this.seconds;
- }
-
- public toObject() {
- return { hours: this.hours, minutes: this.minutes, seconds: this.seconds };
- }
-
- private static padZero(i: string | number) {
- if (typeof i === "string") {
- i = parseInt(i);
- }
- if (i < 10) {
- i = "0" + i;
- }
- return i;
- }
-}
-
/**
* TypeScript Mapping of model.VideoSection
*/
diff --git a/web/ts/utilities/fetch-wrappers.ts b/web/ts/utilities/fetch-wrappers.ts
index cdf32e896..797912d56 100644
--- a/web/ts/utilities/fetch-wrappers.ts
+++ b/web/ts/utilities/fetch-wrappers.ts
@@ -41,3 +41,33 @@ export async function post(url: string, body: object = {}) {
return res;
});
}
+
+/**
+ * Wrapper for Javascript's fetch function for PUT
+ * @param {string} url URL to fetch
+ * @param {object} body Data object to put
+ * @return {Promise}
+ */
+export async function put(url = "", body: object = {}) {
+ return await fetch(url, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ }).then((res) => {
+ if (!res.ok) {
+ throw Error(res.statusText);
+ }
+ return res;
+ });
+}
+
+/**
+ * Wrapper for Javascript's fetch function for DELETE
+ * @param {string} url URL to fetch
+ * @return {Promise}
+ */
+export async function del(url: string) {
+ return await fetch(url, { method: "DELETE" });
+}
diff --git a/web/ts/utilities/paginator.ts b/web/ts/utilities/paginator.ts
index da58c3991..a0a49331e 100644
--- a/web/ts/utilities/paginator.ts
+++ b/web/ts/utilities/paginator.ts
@@ -1,8 +1,7 @@
export class Paginator {
- private list: T[];
- private split_number: number;
-
- private index: number;
+ protected list: T[];
+ protected split_number: number;
+ protected index: number;
private readonly preloader: Preload;
@@ -39,6 +38,10 @@ export class Paginator {
return this;
}
+ find(callback: (obj: T, i: number, arr?: T[]) => boolean): T {
+ return this.list.find(callback);
+ }
+
hasElements() {
return this.list.length > 0;
}
diff --git a/web/ts/utilities/sliding-window.ts b/web/ts/utilities/sliding-window.ts
new file mode 100644
index 000000000..276160426
--- /dev/null
+++ b/web/ts/utilities/sliding-window.ts
@@ -0,0 +1,38 @@
+import { Paginator } from "./paginator";
+
+export class SlidingWindow extends Paginator {
+ constructor(list: T[], split_number: number) {
+ super(list, split_number);
+ }
+
+ get(sortFn?: CompareFunction): T[] {
+ const copy = [...this.list].filter(this.filterPred.bind(this));
+ return sortFn
+ ? copy.sort(sortFn).slice(0, this.index * this.split_number)
+ : copy.slice(0, this.index * this.split_number);
+ }
+
+ isInWindow(o: T): boolean {
+ const i = this.list.findIndex((o1) => o1 === o);
+ return i !== -1 && this.filterPred(o, i);
+ }
+
+ show(o: T) {
+ const i = this.list.findIndex((o1) => o1 === o);
+ this.index = Math.floor(i / this.split_number) + 1;
+ }
+
+ prev() {
+ this.index--;
+ }
+
+ hasPrev() {
+ return this.index > 1;
+ }
+
+ private filterPred(o: T, index: number): boolean {
+ return index >= (this.index - 1) * this.split_number && index < this.index * this.split_number;
+ }
+}
+
+type CompareFunction = (a: T, b: T) => number;
diff --git a/web/ts/utilities/time.ts b/web/ts/utilities/time.ts
new file mode 100644
index 000000000..b1c4bb20f
--- /dev/null
+++ b/web/ts/utilities/time.ts
@@ -0,0 +1,50 @@
+/**
+ * Time Utility Class
+ * Conversion of seconds to (hours, minutes, seconds) and vice versa.
+ */
+export class Time {
+ private readonly hours: number;
+ private readonly minutes: number;
+ private readonly seconds: number;
+
+ static FromSeconds(seconds: number): Time {
+ const date = new Date(seconds * 1000);
+ return new Time(date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());
+ }
+
+ constructor(hours = 0, minutes = 0, seconds = 0) {
+ this.hours = hours;
+ this.minutes = minutes;
+ this.seconds = seconds;
+ }
+
+ public toString() {
+ let s = `${Time.padZero(this.minutes)}:${Time.padZero(this.seconds)}`;
+ if (this.hours > 0) {
+ s = `${Time.padZero(this.hours)}:` + s;
+ }
+ return s;
+ }
+
+ public toStringWithLeadingZeros() {
+ return `${Time.padZero(this.hours)}:${Time.padZero(this.minutes)}:${Time.padZero(this.seconds)}`;
+ }
+
+ public toSeconds(): number {
+ return this.hours * 60 * 60 + this.minutes * 60 + this.seconds;
+ }
+
+ public toObject() {
+ return { hours: this.hours, minutes: this.minutes, seconds: this.seconds };
+ }
+
+ private static padZero(i: string | number) {
+ if (typeof i === "string") {
+ i = parseInt(i);
+ }
+ if (i < 10) {
+ i = "0" + i;
+ }
+ return i;
+ }
+}
diff --git a/web/ts/video-sections.ts b/web/ts/video-sections.ts
index a436b8830..1bb6d74bb 100644
--- a/web/ts/video-sections.ts
+++ b/web/ts/video-sections.ts
@@ -1,112 +1,6 @@
-import { Section, Time } from "./global";
+import { Time } from "./global";
import { DataStore } from "./data-store/data-store";
-import { UpdateVideoSectionRequest } from "./data-store/video-sections";
-
-export abstract class VideoSectionList {
- private streamId: number;
-
- protected list: Section[];
-
- currentHighlightIndex: number;
-
- protected constructor(streamId: number) {
- this.streamId = streamId;
- this.list = [];
- this.currentHighlightIndex = -1;
- DataStore.videoSections.subscribe(this.streamId, (data) => this.onUpdate(data));
- }
-
- private onUpdate(data: Section[]) {
- this.list = data;
- }
-
- abstract getList(): Section[];
-
- abstract isCurrent(i: number): boolean;
-}
-
-/**
- * Mobile VideoSection Functionality
- * @category watch-page
- */
-export class VideoSectionsMobile extends VideoSectionList {
- minimize: boolean;
-
- constructor(streamId: number) {
- super(streamId);
- this.minimize = true;
- }
-
- getList(): Section[] {
- return this.minimize && this.list.length > 0 ? [this.list.at(this.currentHighlightIndex)] : this.list;
- }
-
- isCurrent(i: number): boolean {
- return this.minimize ? true : this.currentHighlightIndex !== -1 && i === this.currentHighlightIndex;
- }
-}
-
-/**
- * Desktop VideoSection Functionality
- * @category watch-page
- */
-export class VideoSectionsDesktop extends VideoSectionList {
- readonly sectionsPerGroup: number;
-
- private followSections: boolean;
- private currentIndex: number;
-
- constructor(streamId: number) {
- super(streamId);
- this.currentIndex = 0;
- this.followSections = false;
- this.sectionsPerGroup = 4;
- }
-
- getList(): Section[] {
- const currentHighlightPage = Math.floor(this.currentHighlightIndex / this.sectionsPerGroup);
- const startIndex = this.followSections && this.validHighlightIndex() ? currentHighlightPage : this.currentIndex;
- return this.list.slice(
- startIndex * this.sectionsPerGroup,
- startIndex * this.sectionsPerGroup + this.sectionsPerGroup,
- );
- }
-
- isCurrent(i: number): boolean {
- const idx =
- this.currentHighlightIndex -
- Math.floor(this.currentHighlightIndex / this.sectionsPerGroup) * this.sectionsPerGroup;
- return this.validHighlightIndex() && this.onCurrentPage() && i === idx;
- }
-
- showNext(): boolean {
- return this.currentIndex < this.list.length / this.sectionsPerGroup - 1;
- }
-
- showPrev(): boolean {
- return this.currentIndex > 0;
- }
-
- next() {
- this.currentIndex = (this.currentIndex + 1) % this.list.length;
- }
-
- prev() {
- this.currentIndex = (this.currentIndex - 1) % this.list.length;
- }
-
- private validHighlightIndex(): boolean {
- return this.currentHighlightIndex !== -1;
- }
-
- private onCurrentPage(): boolean {
- const currentHighlightPage = Math.floor(this.currentHighlightIndex / this.sectionsPerGroup);
- return (
- (this.followSections ? currentHighlightPage : this.currentIndex) ===
- Math.floor(this.currentHighlightIndex / this.sectionsPerGroup)
- );
- }
-}
+import { Section, UpdateVideoSectionRequest } from "./api/video-sections";
/**
* Admin Page VideoSection Management
@@ -190,6 +84,7 @@ export class VideoSectionsAdminController {
startMinutes: 0,
startSeconds: 0,
streamID: this.streamId,
+ isCurrent: false,
};
}
}