-
Notifications
You must be signed in to change notification settings - Fork 45
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
Changes from 9 commits
659f6e5
c641165
3c32ca9
370c59e
00733ba
485f5d8
080a3bf
b6ff401
8e88991
2307c39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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}} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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"> | ||||||
|
@@ -424,6 +424,22 @@ | |||||
</div> | ||||||
{{end}} | ||||||
|
||||||
<!-- Transcript --> | ||||||
<div id="transcript-desktop" x-cloak="" x-show="sidebar === watch.SidebarState.Transcript && transcriptAvailable" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
: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" | ||||||
|
@@ -475,14 +491,21 @@ | |||||
|
||||||
{{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" | ||||||
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'"> | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This way we don't need to run this string through javascript
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like we made this mistake before in these files, feel free to clean them up if you want, if not no worries. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for pointing that out; I removed all the unnecessary |
||||||
<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" | ||||||
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> | ||||||
|
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); | ||
} | ||
} |
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"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume this was unused? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
} | ||
|
||
/* | ||
|
@@ -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"; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need to pass any context, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍