-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Show transcript for lectures with subtitles (#1403)
* 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
1 parent
d3a11f6
commit d99062c
Showing
9 changed files
with
250 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters