diff --git a/web/admin.go b/web/admin.go
index 40a705cc9..b393831d3 100644
--- a/web/admin.go
+++ b/web/admin.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"regexp"
+ "strings"
"github.com/TUM-Dev/gocast/dao"
"github.com/TUM-Dev/gocast/model"
@@ -209,6 +210,45 @@ func (r mainRoutes) LectureStatsPage(c *gin.Context) {
}
}
+func (r mainRoutes) LectureLiveManagementPage(c *gin.Context) {
+ foundContext, exists := c.Get("TUMLiveContext")
+ if !exists {
+ sentry.CaptureException(errors.New("context should exist but doesn't"))
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+ tumLiveContext := foundContext.(tools.TUMLiveContext)
+ indexData := NewIndexData()
+ indexData.TUMLiveContext = tumLiveContext
+ stream := tumLiveContext.Stream
+
+ if stream == nil {
+ tools.RenderErrorPage(c, http.StatusNotFound, "Lecture not found")
+ return
+ }
+
+ if !stream.LiveNow {
+ tools.RenderErrorPage(c, http.StatusNotFound, "Lecture is not live")
+ return
+ }
+
+ if c.Query("restart") == "1" {
+ c.Redirect(http.StatusFound, strings.Split(c.Request.RequestURI, "?")[0])
+ return
+ }
+
+ if err := templateExecutor.ExecuteTemplate(c.Writer, "lecture-live-management.gohtml", LiveLectureManagementData{
+ IndexData: indexData,
+ Lecture: *tumLiveContext.Stream,
+ ChatData: ChatData{
+ IsAdminOfCourse: tumLiveContext.UserIsAdmin(),
+ IndexData: indexData,
+ },
+ }); err != nil {
+ sentry.CaptureException(err)
+ }
+}
+
func (r mainRoutes) CourseStatsPage(c *gin.Context) {
foundContext, exists := c.Get("TUMLiveContext")
if !exists {
@@ -390,3 +430,9 @@ type LectureStatsPageData struct {
IndexData IndexData
Lecture model.Stream
}
+
+type LiveLectureManagementData struct {
+ IndexData IndexData
+ Lecture model.Stream
+ ChatData ChatData
+}
diff --git a/web/assets/css/watch.css b/web/assets/css/watch.css
index 1e94242f0..c8111455c 100644
--- a/web/assets/css/watch.css
+++ b/web/assets/css/watch.css
@@ -10,4 +10,28 @@
width: 100%;
height: auto;
overflow: visible;
+}
+
+.pulsing-circle {
+ width: 2ch;
+ height: 2ch;
+ border-radius: 50%;
+ background-color: red;
+ animation: pulse 2s infinite;
+ margin-left: .2ch;
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.5;
+ transform: scale(1.2);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
}
\ No newline at end of file
diff --git a/web/router.go b/web/router.go
index 3ac1e7a8a..4029a410b 100755
--- a/web/router.go
+++ b/web/router.go
@@ -141,6 +141,7 @@ func configMainRoute(router *gin.Engine) {
withStream.GET("/admin/units/:courseID/:streamID", routes.LectureUnitsPage)
withStream.GET("/admin/cut/:courseID/:streamID", routes.LectureCutPage)
withStream.GET("/admin/stats/:courseID/:streamID", routes.LectureStatsPage)
+ withStream.GET("/admin/management/:courseID/:streamID", routes.LectureLiveManagementPage)
// login/logout/password-mgmt
router.POST("/login", routes.LoginHandler)
diff --git a/web/template/admin/lecture-live-management.gohtml b/web/template/admin/lecture-live-management.gohtml
new file mode 100644
index 000000000..76eead59d
--- /dev/null
+++ b/web/template/admin/lecture-live-management.gohtml
@@ -0,0 +1,197 @@
+
+
+{{- /*gotype: github.com/TUM-Dev/gocast/web.LiveLectureManagementData*/ -}}
+
+
+ {{$stream := .IndexData.TUMLiveContext.Stream}}
+ {{$course := .IndexData.TUMLiveContext.Course}}
+ {{$displayName := or $stream.Name (printf "Lecture: %s %02d. %d" $stream.Start.Month $stream.Start.Day $stream.Start.Year)}}
+ {{.IndexData.Branding.Title}} | {{$course.Name}}: {{$displayName}}
+ {{template "headImports" .IndexData.VersionTag}}
+
+
+
+
+
+
+
+
+
+ {{/* Remove this when using KaTeX in other locations than the chat */}}
+ {{if $stream.ChatEnabled}}
+
+
+
+
+ {{end}}
+
+
+ {{template "header" .IndexData.TUMLiveContext}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{if $stream.PlaylistUrl}}
+
+ {{else}}
+
+ {{end}}
+
+
+
+ {{if eq .Lecture.Description ""}}
+ No description available
+ {{else}}
+ {{.Lecture.Description}}
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Video Stats
+
+
+
+
Resolution:
+
Bandwidth:
+
+
+
+
+
Buffer:
+
+
+
> Buffered Time:
+
> Chunks Requested:
+
> Requests Failed:
+
+
+
+
+
+
+
+
+ {{if and $course.ChatEnabled $stream.ChatEnabled}}
+
+
+ {{template "chat-component" .ChatData}}
+
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
+
+
Keep the recording after ending the stream?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/ts/components/video-information.ts b/web/ts/components/video-information.ts
index d357783c5..111b1e33f 100644
--- a/web/ts/components/video-information.ts
+++ b/web/ts/components/video-information.ts
@@ -46,3 +46,20 @@ export function videoInformationContext(streamId: number): AlpineComponent {
},
} as AlpineComponent;
}
+
+function updateLiveTimeLeft(streamEnd: Date) {
+ const now = new Date();
+ const timeLeft = streamEnd.getTime() - now.getTime();
+ const timeLeftAbs = Math.abs(timeLeft) / 1000;
+ document.getElementById("live-time-remaining").innerHTML =
+ "Time left: " +
+ (timeLeft < 0 ? "-" : "") +
+ Math.floor(timeLeftAbs / 60) +
+ ":" +
+ ("0" + Math.floor(timeLeftAbs % 60)).slice(-2);
+}
+
+export function periodicUpdateLiveTimeLeft(streamEnd: string) {
+ const streamEndDate = new Date(streamEnd.match("\\d\\d\\d\\d-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d")[0]);
+ setInterval(() => updateLiveTimeLeft(streamEndDate), 1000);
+}
diff --git a/web/ts/entry/video.ts b/web/ts/entry/video.ts
index 7356c475b..14cfca3a9 100644
--- a/web/ts/entry/video.ts
+++ b/web/ts/entry/video.ts
@@ -8,3 +8,4 @@ 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
+export * from "../interval-updates";
diff --git a/web/ts/interval-updates.ts b/web/ts/interval-updates.ts
new file mode 100644
index 000000000..c8324e959
--- /dev/null
+++ b/web/ts/interval-updates.ts
@@ -0,0 +1,4 @@
+export function periodicCurrentTime(id: string) {
+ const time = document.getElementById(id);
+ setInterval(() => (time.innerHTML = new Date().toLocaleTimeString()), 1000);
+}
diff --git a/web/ts/watch.ts b/web/ts/watch.ts
index fc7bbac49..61d9c6d6e 100644
--- a/web/ts/watch.ts
+++ b/web/ts/watch.ts
@@ -133,6 +133,76 @@ export class ShareURL {
}
}
+export async function setupAudioMeter() {
+ console.log("Setting up audio meter");
+ const video = document.getElementsByTagName("video")[0];
+ const audioMeter = document.getElementById("audio-meter");
+ const audioContext = new AudioContext();
+ const audioSource = audioContext.createMediaElementSource(video);
+ const analyser = audioContext.createAnalyser();
+
+ audioSource.connect(analyser);
+ analyser.connect(audioContext.destination);
+ console.log("Audio meter setup complete");
+
+ const bufferLength = analyser.fftSize;
+ const frequencyData = new Uint8Array(bufferLength);
+
+ function updateAudioMeter() {
+ analyser.getByteFrequencyData(frequencyData);
+
+ // Calculate average volume
+ const averageVolume = frequencyData.reduce((sum, value) => sum + value, 0) / bufferLength;
+
+ // Update the audio meter (adjust styling as needed)
+ audioMeter.style.width = `${(averageVolume / 255) * 100}%`;
+ audioMeter.style.backgroundColor = `rgb(${averageVolume}, 0, 0)`;
+
+ requestAnimationFrame(updateAudioMeter);
+ }
+
+ updateAudioMeter();
+}
+
+export function seekToLive() {
+ const players = getPlayers();
+ console.log("Seeking to live edge");
+ console.debug(players);
+ players.forEach((player) => {
+ player.liveTracker.seekToLiveEdge();
+ });
+}
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+function getHighestQualityLevel(qualityLevels: any[]): number {
+ let highestQuality = qualityLevels[0];
+ for (let i = 1; i < qualityLevels.length; i++) {
+ if (qualityLevels[i].height > highestQuality.height) {
+ highestQuality = qualityLevels[i];
+ }
+ }
+ return qualityLevels.indexOf(highestQuality);
+}
+
+export function setHighestQuality() {
+ const players = getPlayers();
+ console.debug(players);
+ players.forEach((player) => {
+ const qualityLevels = (player as any).qualityLevels();
+ const highestQuality = getHighestQualityLevel(qualityLevels.levels_);
+ // Listen to change events for when the player selects a new quality level
+ qualityLevels.on("change", function () {
+ console.debug("Quality Level changed!");
+ console.debug("New level:", qualityLevels[qualityLevels.selectedIndex]);
+ });
+ qualityLevels.trigger({ type: "change", selectedIndex: highestQuality });
+ qualityLevels.selectedIndex_ = highestQuality;
+ for (let i = 0; i < qualityLevels.length; i++) {
+ qualityLevels[i].enabled = i == highestQuality;
+ }
+ });
+}
+
export function pauseVideo() {
const player = getPlayers()[0];
player.pause();