Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Video Section Component #1084

Merged
merged 11 commits into from
Aug 17, 2023
158 changes: 37 additions & 121 deletions web/template/partial/stream/video-sections.gohtml
Original file line number Diff line number Diff line change
@@ -1,127 +1,43 @@
{{define "videosections"}}
<div>
<div class="hidden md:block">
{{template "videosections-desktop" .ID}}
</div>
<div class="md:hidden">
{{template "videosections-mobile" .ID}}
</div>
</div>
{{end}}

{{define "videosections-desktop"}}
<div x-data="{vs: new watch.VideoSectionsDesktop({{.}})}"
x-init="watch.attachCurrentTimeEvent(vs);">
<div class="group relative">
<div class="grid gap-1 px-2">
<div class="flex justify-between items-center border-b mb-3 dark:border-gray-800 lg:justify-start">
<h3 class="text-4 font-semibold">Sections</h3>
<button @click="vs.followSections = !vs.followSections;"
class="text-xs rounded h-fit w-fit font-semibold uppercase lg:ml-3 px-1"
:class="vs.followSections ? 'text-7' : 'hover:bg-gray-200 dark:hover:bg-gray-600 text-5 hover:text-1'"
:disabled="vs.followSections">
Follow sections
<article x-data="watch.videoSectionContext({{.ID}})" class = "p-3 lg:p-0 lg:h-48 overflow-y-clip">
<header class="flex space-x-2 mb-3 text-3 justify-between lg:justify-start">
<h3 class="font-bold">Sections</h3>
<button @click="autoScroll.toggle()"
class="tum-live-button tum-live-button-tertiary"
:class="{'active' : autoScroll.value}">
Auto-Scroll
</button>
</header>
<article class="relative flex flex-col space-y-2 lg:space-y-0 lg:space-x-2 lg:flex-row lg:items-stretch">
<template x-if="sections.hasNext()">
<section class = "flex items-end absolute -left-2 z-40 h-full lg:items-baseline lg:pt-8 lg:left-auto lg:-right-4">
<button type="button" @click="nextSection()"
class="tum-live-icon-button tum-live-border tum-live-bg text-3 border rounded-full h-8 w-8">
<i class="fa-solid fa-chevron-right rotate-90 lg:rotate-0"></i>
</button>
</div>
<div class="relative flex w-fit">
<template x-if="vs.showPrev()">
<button @click="vs.prev();vs.followSections = false;"
class="group-hover:block hidden absolute -left-4 z-50 bg-white border shadow text-sm py-2 px-3 my-auto h-fit top-0 bottom-0 rounded-lg hover:bg-gray-50 hover:dark:bg-gray-600 dark:border-gray-800 dark:bg-secondary">
<i class="fa fa-chevron-left text-3"></i>
</button>
</template>
<template x-if="vs.showNext()">
<button @click="vs.next();vs.followSections = false;"
class="group-hover:block hidden absolute -right-2 z-50 bg-white border shadow text-sm py-2 px-3 my-auto h-fit top-0 bottom-0 rounded-lg hover:bg-gray-50 hover:dark:bg-gray-600 dark:border-gray-800 dark:bg-secondary">
<i class="fa fa-chevron-right text-3"></i>
</button>
</template>
<template x-for="(s, i) in vs.getList()" :key="s.ID">
<button x-cloak
class="relative flex h-40 w-32 mb-1 mr-2 bg-transparent outline-none border-0 rounded-lg"
x-data="{previewImgLoaded: false}"
@click="watch.jumpTo({ timeParts: {hours: s.startHours, minutes: s.startMinutes, seconds: s.startSeconds }}); vs.followSections = false;">
<span class="flex flex-col h-full p-1 bg-white dark:bg-secondary justify-between w-full border hover:dark:bg-gray-600 hover:bg-gray-200 rounded-lg"
:class="vs.isCurrent(i) ? 'border-2 border-blue-500/50 dark:border-indigo-600/50' : 'dark:border-gray-800'">
<template x-if="s.fileID !== 0">
<img x-cloak x-show="previewImgLoaded"
src=""
:src="`/api/download/${s.fileID}?type=serve`"
width="128" height="32"
@load="previewImgLoaded=true"
alt="preview"
class="w-full h-16 rounded object-cover z-10">
</template>
<span x-show="!previewImgLoaded"
class="block w-full h-16 bg-gray-50 dark:bg-gray-700 rounded"></span>
<span x-text="s.description"
class="block text-left text-xs text-3 mt-2 mb-auto px-1"></span>
<span class="absolute bottom-1 right-1 px-1 py-1">
<span x-text="s.friendlyTimestamp"
class="block text-sky-800 ml-auto w-fit bg-sky-200 text-xs dark:text-indigo-200 dark:bg-indigo-800 p-1 rounded">
</span>
</span>
</span>
</button>
</template>
</div>
</div>
</div>
</div>
{{end}}

{{define "videosections-mobile"}}
<div x-data="{vs: new watch.VideoSectionsMobile({{.}})}"
x-init="watch.attachCurrentTimeEvent(vs);" class="pt-3">
<div class="flex justify-between items-center border-b dark:border-gray-800 mb-3">
<h3 class="text-4 font-semibold">Sections</h3>
<template x-if="!vs.minimize">
<button @click="vs.minimize = true;"
class="text-5 text-xs rounded h-fit w-fit font-semibold px-2 py-1 uppercase">
Minimize
</button>
</section>
</template>
<template x-if="vs.minimize">
<button @click="vs.minimize = false;"
class="text-5 text-xs rounded h-fit w-fit font-semibold px-2 py-1 uppercase">
Show all
</button>
<template x-if="sections.hasPrev()">
<section class = "flex items-baseline absolute -left-2 z-40 h-full lg:items-baseline lg:pt-8 lg:-left-4">
<button type="button" @click="prevSection()"
class="tum-live-icon-button tum-live-border tum-live-bg text-3 border rounded-full h-8 w-8">
<i class="fa-solid fa-chevron-left rotate-90 lg:rotate-0"></i>
</button>
</section>
</template>
</div>
<div class="p-1 border bg-gray-100 rounded dark:bg-gray-800 dark:border-gray-800">
<div class="grid gap-1 overflow-y-scroll" :class="vs.minimize ? 'h-fit' : 'h-56'">
<template x-for="(s, i) in vs.getList()" :key="s.ID">
<button x-cloak class="flex w-full bg-transparent outline-none border-0 rounded-lg"
x-data="{previewImgLoaded: false}"
@click="watch.jumpTo({ timeParts: {hours: s.startHours, minutes: s.startMinutes, seconds: s.startSeconds }})">
<span class="flex justify-start p-1 bg-white dark:bg-secondary w-full border hover:dark:bg-gray-600 hover:bg-gray-200 rounded-lg"
:class="vs.isCurrent(i) ? 'border-2 border-blue-500/50 dark:border-indigo-600/50' : 'dark:border-gray-800'">
<span class="block">
<template x-if="s.fileID !== 0">
<img x-cloak x-show="previewImgLoaded"
src=""
:src="`/api/download/${s.fileID}?type=serve`"
width="128" height="32"
@load="previewImgLoaded=true"
alt="preview"
class="w-full h-16 rounded object-cover z-10">
</template>
<span x-show="!previewImgLoaded"
class="block rounded w-36 h-16 bg-gray-50 dark:bg-gray-600"></span>
<template x-for="s in sections.get()" :key="s.ID">
<button type="button"
@click="watch.jumpTo({ timeParts: {hours: s.startHours, minutes: s.startMinutes, seconds: s.startSeconds }});"
class="flex flex-row h-16 group rounded-lg lg:flex-col lg:h-auto lg:w-36">
<span :style="`background-image:url('/api/download/${s.fileID}?type=serve')`"
class="relative block shrink-0 h-full aspect-video bg-gray-100 border-2 rounded-lg dark:bg-gray-800 dark:shadow-gray-900/75 lg:group-hover:shadow-lg lg:h-auto lg:w-full"
:class="s.isCurrent ? 'border-blue-500/50 dark:border-indigo-600/50 shadow-lg' : 'border-transparent'">
<span class="tum-live-badge text-xs text-sky-800 bg-sky-200 dark:text-indigo-200 dark:bg-indigo-800 absolute bottom-2 right-2 px-1 py-1px"
x-text="s.friendlyTimestamp"></span>
</span>
<span class="block ml-2">
<span x-text="s.description"
class="block text-left text-xs text-3 my-2 px-1"></span>
<span class="flex flex-grow items-end flex-1 px-1 py-1">
<span x-text="s.friendlyTimestamp"
class="block text-sky-800 w-fit bg-sky-200 text-xs dark:text-indigo-200 dark:bg-indigo-800 p-1 rounded">
</span>
</span>
</span>
</span>
</button>
</template>
</div>
</div>
</div>
<span x-text="s.description" class="block font-semibold overflow-ellipsis text-xs text-3 text-left p-1 my-auto pl-3 lg:my-0 lg:pl-1"></span>
</button>
</template>
</article>
</article>
{{end}}
28 changes: 0 additions & 28 deletions web/ts/TUMLiveVjs.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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") === "";

Expand All @@ -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) => {
Expand Down
44 changes: 44 additions & 0 deletions web/ts/api/video-sections.ts
Original file line number Diff line number Diff line change
@@ -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<Section[]> {
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<Response> {
return del(`/api/stream/${streamId}/sections/${id}`);
},
};
57 changes: 57 additions & 0 deletions web/ts/components/video-sections.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 6 additions & 39 deletions web/ts/data-store/video-sections.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
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<number, Section[]> {
protected async fetcher(streamId: number): Promise<Section[]> {
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;
});
}

async add(streamId: number, sections: Section[]): Promise<void> {
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 = {
Expand All @@ -43,37 +44,3 @@ export class VideoSectionProvider extends StreamableMapProvider<number, Section[
await this.triggerUpdate(streamId);
}
}

export class UpdateVideoSectionRequest {
Description: string;
StartHours: number;
StartMinutes: number;
StartSeconds: number;
}

/**
* Wrapper for REST-API calls @ /api/stream/:id/sections
* @category watch-page
* @category admin-page
*/
const VideoSections = {
get: async function (streamId: number): Promise<Section[]> {
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<Response> {
return Delete(`/api/stream/${streamId}/sections/${id}`);
},
};
1 change: 1 addition & 0 deletions web/ts/entry/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading