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}} +
+ +
+

{{$stream.Name}}

+ | +

{{$course.Name}}

+
+ +
+
+
+
+
+ Time left: +
+ +
+
+
+ +
+ + {{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();