Skip to content

Commit

Permalink
Video Section Component (#1084)
Browse files Browse the repository at this point in the history
* Add VideoSectionAPI; Reutilize ts utilities

* Add slidingWindow and Time

* Add watchers

* Update highlight search

* Add back follow sections

* Remove old code

* Update css

* Update mobile css

* Small css changes

* Uncomment chat
  • Loading branch information
MatthiasReumann authored and SebiWrn committed May 7, 2024
1 parent 96fb947 commit e43f18a
Show file tree
Hide file tree
Showing 12 changed files with 274 additions and 351 deletions.
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

0 comments on commit e43f18a

Please sign in to comment.