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

Show transcript for lectures with subtitles #1403

Merged
merged 10 commits into from
Dec 5, 2024
4 changes: 2 additions & 2 deletions web/template/admin/admin_tabs/info-pages.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@
</div>
</div>
</div>
<div x-cloak="" x-show="editOpen" class="form-container-body">
<div x-cloak x-show="editOpen" class="form-container-body">
<textarea id="content-{{$i}}"
x-model="newContent"
rows="24"
class="p-2 resize-none font-normal outline-0 border-0 bg-transparent text-3">{{$text.RawContent}}</textarea>
</div>
<div x-cloak="" x-show="editOpen" class="flex items-center justify-end form-container-footer">
<div x-cloak x-show="editOpen" class="flex items-center justify-end form-container-footer">
<button id="save-{{$i}}"
type="reset"
class="btn h-fit mr-2"
Expand Down
4 changes: 2 additions & 2 deletions web/template/course-overview.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
{{if or (.IndexData.TUMLiveContext.User.IsAdminOfCourse .IndexData.TUMLiveContext.Course) .IndexData.IsAdmin}}
<a class="hover:bg-gray-200 dark:hover:bg-gray-600 w-fit mx-2 inline-block rounded px-2 my-auto"
href="/admin/course/{{$course.Model.ID}}"
:title="'Edit course settings'">
title="Edit course settings">
<span class="font-semibold text-lg dark:text-white">
<i class="fa-solid w-5 py-2 fa-pen"></i>
</span>
Expand Down Expand Up @@ -125,7 +125,7 @@
</button>
<a class="hover:bg-gray-200 dark:hover:bg-gray-600 inline-block rounded px-2"
href="/api/download_ics/{{$course.Year}}/{{$course.TeachingTerm}}/{{$course.Slug}}/events.ics"
:title="'Export lecture dates'"
title="Export lecture dates"
x-show="lectures">
<span class="text-sm font-semibold uppercase dark:text-white">
<i class="fa-solid w-5 mr-1 fa-calendar"></i>ics
Expand Down
43 changes: 43 additions & 0 deletions web/template/partial/stream/transcript.gohtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{{define "transcript-modal"}}
<div x-data="{ transcriptController: new watch.TranscriptController(), isOutOfSync: false, showScrollUp: false }"
class="relative h-full border rounded-lg dark:border-gray-800 flex flex-col items-center justify-center overflow-hidden">
<div class="h-full w-full max-w-3xl flex flex-col">
<div class="flex justify-between items-end p-4">
<div class="flex items-center space-x-2">
<h3 class="font-bold text-sm md:text-xl text-3">Transcript</h3>
<span class="text-xs font-semibold text-white bg-red-500 rounded-full px-2 py-1 italic">beta</span>
</div>
<button type="button" title="Download transcript file"
class="text-3 text-xs md:text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full px-2 py-1"
@click="transcriptController.downloadTranscript()">
Export transcript
</button>
</div>
<div class="flex-grow overflow-hidden relative">
{{template "transcript-list"}}
</div>
</div>
</div>
{{end}}

{{define "transcript-list"}}
<div x-cloak
x-data="{ transcriptController: new watch.TranscriptController(), transcript: [] }"
x-init="() => { transcriptController.init('transcript-list', $el); }"
@update="(e) => (transcript = e.detail)"
class="h-full w-full max-w-3xl mx-auto transcript-container overflow-hidden">
<div class="relative grid gap-1 overflow-y-auto pr-3 h-full">
<template x-for="(cue, index) in transcript" :key="index">
<div class="flex items-start space-x-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded"
@click="watch.jumpTo({ Ms: cue.startTime * 1000 }, true);" :data-cue-start="cue.startTime">
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<span x-text="new Date(cue.startTime * 1000).toISOString().substr(11, 8)"></span>
</div>
<div class="text-sm text-gray-900 dark:text-gray-100">
<span x-text="cue.text"></span>
</div>
</div>
</template>
</div>
</div>
{{end}}
43 changes: 33 additions & 10 deletions web/template/watch.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<script defer src="/static/node_modules/katex/dist/contrib/copy-tex.min.js"></script>
{{end}}
</head>
<body x-data="{'streamID': {{$stream.Model.ID}}, seekLogger: new watch.SeekLogger('{{$stream.ID}}'), sidebar: $persist(watch.SidebarState.Hidden).as('sidebarState'), showShare: false}"
<body x-data="{'streamID': {{$stream.Model.ID}}, seekLogger: new watch.SeekLogger('{{$stream.ID}}'), sidebar: $persist(watch.SidebarState.Hidden).as('sidebarState'), showShare: false, transcriptAvailable: false}"
x-init="seekLogger.attach();">
{{template "header" .IndexData.TUMLiveContext}}
<div id="shortcuts-help-modal" class="hidden flex fixed top-0 h-screen w-screen z-50 backdrop-brightness-50">
Expand Down Expand Up @@ -73,7 +73,7 @@
</div>
</div>
{{if .IndexData.TUMLiveContext.User}}
<div id="bookmarks-mobile" x-cloak="" x-show="sidebar === watch.SidebarState.Bookmarks"
<div id="bookmarks-mobile" x-cloak x-show="sidebar === watch.SidebarState.Bookmarks"
class="md:hidden flex absolute top-0 h-screen w-screen z-50 backdrop-brightness-50">
<div @click.outside="sidebar = watch.SidebarState.Hidden"
class="m-auto w-3/4 h-16/9 bg-white dark:bg-secondary-light border dark:border-gray-800 rounded-lg">
Expand Down Expand Up @@ -417,16 +417,32 @@

<!-- Bookmarks -->
{{if .IndexData.TUMLiveContext.User}}
<div id="bookmarks-desktop" x-cloak="" x-show="sidebar === watch.SidebarState.Bookmarks"
<div id="bookmarks-desktop" x-cloak x-show="sidebar === watch.SidebarState.Bookmarks"
:class="sidebar === watch.SidebarState.Bookmarks ? 'lg:basis-1/4' : 'lg:basis-0'"
class="hidden md:block basis-full h-16/9 lg:h-16/6 px-5 md:px-2 lg:order-none order-4">
{{template "bookmarks-modal" $stream.ID}}
</div>
{{end}}

<!-- Transcript -->
<div id="transcript-desktop" x-cloak x-show="sidebar === watch.SidebarState.Transcript && transcriptAvailable"
:class="sidebar === watch.SidebarState.Transcript ? 'lg:basis-1/4' : 'lg:basis-0'"
class="basis-full md:h-16/9 lg:h-16/6 px-2 lg:order-none order-4 grow">
{{template "transcript-modal" $stream.ID}}
</div>

<!-- Scroll to Top Button -->
<button x-data="{ showScrollUp: false }"
x-show="showScrollUp"
@click="watch.pauseVideo();window.scrollTo({ top: 0, behavior: 'smooth' }); "
class="fixed bottom-4 right-4 md:hidden bg-blue-500 text-white rounded-full p-2 shadow-lg z-50"
@scroll.window="showScrollUp = (window.scrollY > 200)">
<i class="fa-solid fa-arrow-up"></i>
</button>

<!-- Streams -->
{{if .IndexData.TUMLiveContext.User}}
<div id="streams-box" x-cloak="" x-show="sidebar === watch.SidebarState.Streams"
<div id="streams-box" x-cloak x-show="sidebar === watch.SidebarState.Streams"
:class="sidebar === watch.SidebarState.Streams ? 'lg:basis-1/4' : 'lg:basis-0'"
class="order-4 z-20 basis-full px-5 md:px-2 lg:order-none lg:h-16/6 h-16/9">
{{template "playlist" $stream.ID}}
Expand Down Expand Up @@ -462,28 +478,35 @@
<div class="rounded-lg px-3 text-4 py-2 h-fit w-fit bg-gray-100 dark:bg-secondary-light space-x-2">
<a
href="/admin/course/{{$course.Model.ID}}#lecture-li-{{$stream.Model.ID}}"
:title="'Edit course settings'">
title="Edit course settings">
<i class="fa-solid fa-pen text-4"></i>
</a>
<a
href="/admin/stats/{{$course.Model.ID}}/{{$stream.Model.ID}}"
:title="'Watch lecture stats'">
title="Watch lecture stats">
<i class="fa-solid fa-chart-simple text-4"></i>
</a>
</div>
{{end}}

{{if .IndexData.TUMLiveContext.User}}
<button @click="sidebar = (sidebar === watch.SidebarState.Bookmarks ? watch.SidebarState.Hidden : watch.SidebarState.Bookmarks)"
class="rounded-lg px-4 py-2 h-fit w-fit bg-gray-100 hover:bg-gray-200 dark:bg-secondary-light dark:hover:bg-gray-600"
:title="'New Bookmark'">
class="rounded-lg px-3 py-1 md:px-4 py-2 h-fit w-fit bg-gray-100 hover:bg-gray-200 dark:bg-secondary-light dark:hover:bg-gray-600"
title="New Bookmark">
<i class="fa-solid fa-bookmark text-4"></i>
</button>
{{end}}

<!-- Transcript Button -->
<button x-show="transcriptAvailable" @toggletranscript.window="e => {transcriptAvailable=true}" @click="sidebar = (sidebar === watch.SidebarState.Transcript ? watch.SidebarState.Hidden : watch.SidebarState.Transcript)"
class="rounded-lg px-3 py-1 md:px-4 py-2 h-fit w-fit bg-gray-100 hover:bg-gray-200 dark:bg-secondary-light dark:hover:bg-gray-600"
title="Show Transcript">
<i class="fa-solid fa-text-height text-4"></i>
</button>

<button @click="showShare = true;"
class="rounded-lg px-4 py-2 h-fit w-fit bg-gray-100 hover:bg-gray-200 dark:bg-secondary-light dark:hover:bg-gray-600"
:title="'Share'">
class="rounded-lg px-3 py-1 md:px-4 py-2 h-fit w-fit bg-gray-100 hover:bg-gray-200 dark:bg-secondary-light dark:hover:bg-gray-600"
title="Share">
<i class="fa-solid fa-share text-4"></i>
</button>

Expand Down
5 changes: 4 additions & 1 deletion web/ts/TUMLiveVjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ export type jumpToSettings = {
S: number | undefined;
};

export function jumpTo(settings: jumpToSettings) {
export function jumpTo(settings: jumpToSettings, autoplay = false) {
if (settings.timeParts) {
settings.time = new Time(settings.timeParts.hours, settings.timeParts.minutes, settings.timeParts.seconds);
} else if (settings.Ms) {
Expand All @@ -646,6 +646,9 @@ export function jumpTo(settings: jumpToSettings) {
for (let j = 0; j < players.length; j++) {
players[j].ready(() => {
players[j].currentTime(settings.time.toSeconds());
if (autoplay && players[j].paused()) {
players[j].play();
}
});
}
}
Expand Down
1 change: 1 addition & 0 deletions web/ts/entry/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "../TUMLiveVjs";
export * from "../watch";
export * from "../splitview";
export * from "../bookmarks";
export * from "../transcript";
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
3 changes: 2 additions & 1 deletion web/ts/track-bars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export async function loadAndSetTrackbars(player: VideoJsPlayer, streamID: numbe
for (const language of LANGUAGES) {
await fetch(`/api/stream/${streamID}/subtitles/${language.id}`).then((res) => {
if (res.ok) {
window.dispatchEvent(new CustomEvent("togglesearch", { detail: { streamID: streamID } }));
window.dispatchEvent(new CustomEvent("togglesearch", { detail: { streamID: streamID } })); // Used for enabling watch searchbar
window.dispatchEvent(new CustomEvent("toggletranscript", { detail: { streamID: streamID } })); // Used for enabling button to show transcript-modal
player.addRemoteTextTrack(
{
src: `/api/stream/${streamID}/subtitles/${language.id}`,
Expand Down
157 changes: 157 additions & 0 deletions web/ts/transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { getPlayers } from "./TUMLiveVjs";
import { VideoJsPlayer } from "video.js";

export class TranscriptController {
static initiatedInstances: Map<string, Promise<TranscriptController>> = new Map();

private list: VTTCue[];
private elem: HTMLElement;
private lastSyncTime: number;
private player: VideoJsPlayer;
private selectedTrackLabel: string;

constructor() {
this.lastSyncTime = 0;
this.selectedTrackLabel = "English";
}

reset(): void {
this.player = getPlayers()[0];
}

async init(key: string, element: HTMLElement) {
if (TranscriptController.initiatedInstances[key]) {
(await TranscriptController.initiatedInstances[key]).unsub();
}
TranscriptController.initiatedInstances[key] = new Promise<TranscriptController>(() => {
this.elem = element;
});

this.player = getPlayers()[0];
window.setInterval(() => this.syncTranscript(), 1000);
}

async syncTranscript() {
const transcriptDesktop = document.getElementById("transcript-desktop");
if (!this.elem || !transcriptDesktop || transcriptDesktop.offsetParent === null) {
return;
}

const now = Date.now();
// Sync once every second
if (now - this.lastSyncTime < 1000 || this.player.paused()) {
return;
}
this.lastSyncTime = now;
console.debug("Syncing transcript...");

const currentTime = this.player.currentTime();
const transcript = await this.fetchTranscript(this.player);
this.updateTranscript(transcript);
this.highlightActiveCue(currentTime);
}

async fetchTranscript(player: VideoJsPlayer): Promise<VTTCue[]> {
const textTracks = player.textTracks();
let transcript: VTTCue[] = [];

// Try to find the selected track first
transcript = this.getTranscriptFromTracks(textTracks, this.selectedTrackLabel);
if (transcript.length > 0) {
return transcript;
}

// If no selected track is found, use other available subtitles
transcript = this.getTranscriptFromTracks(textTracks);
return transcript;
}

getTranscriptFromTracks(textTracks: TextTrackList, label?: string): VTTCue[] {
const transcript: VTTCue[] = [];
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if ((track.kind === "captions" || track.kind === "subtitles") && (!label || track.label === label)) {
for (let j = 0; j < track.cues.length; j++) {
const cue = track.cues[j] as VTTCue;
transcript.push(cue);
}
if (label && transcript.length > 0) {
return transcript;
}
}
}
return transcript;
}

updateTranscript(transcript: VTTCue[]) {
this.list = transcript;
const event = new CustomEvent("update", { detail: transcript });
this.elem.dispatchEvent(event);
}

highlightActiveCue(currentTime: number) {
const activeCue = this.list.find((cue) => cue.startTime <= currentTime && cue.endTime >= currentTime);
const cueElements = this.elem.querySelectorAll("[data-cue-start]");
cueElements.forEach((cueElement: HTMLElement) => {
cueElement.classList.remove("bg-blue-100", "dark:bg-blue-700");
});

if (activeCue) {
const cueElement = this.elem.querySelector(`[data-cue-start="${activeCue.startTime}"]`);
if (cueElement) {
cueElement.classList.add("bg-blue-100", "dark:bg-blue-700");
cueElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}

onUpdate(data: VTTCue[]) {
this.updateTranscript(data);
}

length(): number {
return this.list !== undefined ? this.list.length : 0;
}

async downloadTranscript() {
const player = getPlayers()[0];
const textTracks = player.textTracks();
let transcript = this.getTranscriptText(textTracks, this.selectedTrackLabel);

// If no selected track is found, use other available subtitles
if (transcript === "") {
transcript = this.getTranscriptText(textTracks);
}

this.downloadTextAsFile(transcript, "transcript.txt");
}

getTranscriptText(textTracks: TextTrackList, label?: string): string {
let transcript = "";
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if ((track.kind === "captions" || track.kind === "subtitles") && (!label || track.label === label)) {
for (let j = 0; j < track.cues.length; j++) {
const cue = track.cues[j];
transcript += `${(cue as VTTCue).text}\n\n`;
}
if (label && transcript !== "") {
return transcript;
}
}
}
return transcript;
}

downloadTextAsFile(text: string, filename: string) {
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
7 changes: 6 additions & 1 deletion web/ts/watch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { getPlayers } from "./TUMLiveVjs";
import { copyToClipboard, Time } from "./global";
import { seekbarOverlay } from "./seekbar-overlay";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this was unused?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes (see beginning & end of watch.ts file)


export enum SidebarState {
Hidden = "hidden",
Chat = "chat",
Bookmarks = "bookmarks",
Streams = "streams",
Transcript = "transcript",
}

/*
Expand Down Expand Up @@ -133,6 +133,11 @@ export class ShareURL {
}
}

export function pauseVideo() {
const player = getPlayers()[0];
player.pause();
}

export { repeatHeatMap } from "./repeat-heatmap";
export { seekbarHighlights, MarkerType } from "./seekbar-highlights";
export { seekbarOverlay, SeekbarHoverPosition } from "./seekbar-overlay";
Expand Down
Loading