Skip to content

Commit

Permalink
Show transcript for lectures with subtitles (#1403)
Browse files Browse the repository at this point in the history
* Add transcript synchronization functions

Signed-off-by: carlobortolan <[email protected]>

* Add transcript component to watch.gohtml

Signed-off-by: carlobortolan <[email protected]>

* Update mobile transcript modal

* Add autoplay for transcript click events; clean up transcript.ts

* Fix transcript cue text formatting in TranscriptController

* Update transcript

* Clean up and implement code style suggestions

---------

Signed-off-by: carlobortolan <[email protected]>
  • Loading branch information
carlobortolan authored Dec 5, 2024
1 parent d3a11f6 commit d99062c
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 17 deletions.
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 @@ -467,28 +483,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";

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

0 comments on commit d99062c

Please sign in to comment.