Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6555fb2
Added additional management page
SebiWrn Oct 8, 2024
051b39d
Added livestream and course/lecture name to management page
SebiWrn Oct 9, 2024
a25de30
Added Video Stats
SebiWrn Oct 10, 2024
d0a5849
Added stats and parts of the chat
SebiWrn Oct 10, 2024
fbed0a6
Fixed bad readability issue
SebiWrn Nov 10, 2024
fc34d78
Changed text color of stats
SebiWrn Nov 10, 2024
1b588ca
Merge branch '1167-include-timestamp-text-in-share-dialogue-hardly-re…
SebiWrn Nov 10, 2024
0964c0a
Moved chat to the right
SebiWrn Nov 10, 2024
7e8b45f
Fixed chat and added Seek to Live Button
SebiWrn Nov 10, 2024
568c60e
Fixed layout of seek button
SebiWrn Nov 10, 2024
9f3f3a3
Added function to use highest quality
SebiWrn Nov 10, 2024
803a6ce
Minor fixes
SebiWrn Nov 10, 2024
ea047d9
Added highest quality automatically and added Remaining live time
SebiWrn Nov 10, 2024
b74a723
Added current time to lecture live page
SebiWrn Nov 10, 2024
312921b
Live lecture management page is only usable if stream is live
SebiWrn Nov 10, 2024
024dc04
Changed chat layout
SebiWrn Nov 10, 2024
4bf62fe
Added Restart and Stop button for stream
SebiWrn Nov 12, 2024
f092a40
Some minor changes
SebiWrn Nov 12, 2024
27d8134
Merge branch 'dev' into enh/live-admin-view
SebiWrn Apr 17, 2025
f752343
eslint fix
SebiWrn Apr 17, 2025
445f5cf
Fixed watch.ts
SebiWrn Apr 17, 2025
c179862
eslint fix 2
SebiWrn Apr 17, 2025
abc92a7
gofumpt
SebiWrn Apr 17, 2025
d564742
empty commit to trigger actions
SebiWrn Apr 17, 2025
0650ad1
Fixed catch issues and removed todos
SebiWrn Jun 18, 2025
086f0fe
gofumpted
SebiWrn Jun 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions web/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"regexp"
"strings"

"github.com/TUM-Dev/gocast/dao"
"github.com/TUM-Dev/gocast/model"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -390,3 +430,9 @@ type LectureStatsPageData struct {
IndexData IndexData
Lecture model.Stream
}

type LiveLectureManagementData struct {
IndexData IndexData
Lecture model.Stream
ChatData ChatData
}
24 changes: 24 additions & 0 deletions web/assets/css/watch.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions web/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
197 changes: 197 additions & 0 deletions web/template/admin/lecture-live-management.gohtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en" class="h-full dark">
{{- /*gotype: github.com/TUM-Dev/gocast/web.LiveLectureManagementData*/ -}}
<head>
<meta charset="UTF-8">
{{$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)}}
<title>{{.IndexData.Branding.Title}} | {{$course.Name}}: {{$displayName}}</title>
{{template "headImports" .IndexData.VersionTag}}
<script>window.HELP_IMPROVE_VIDEOJS = false;</script>
<script src="/static/assets/ts-dist/watch.bundle.js?v={{if .IndexData.VersionTag}}{{.IndexData.VersionTag}}{{else}}development{{end}}"></script>
<script src="/static/assets/ts-dist/admin.bundle.js?v={{if .IndexData.VersionTag}}{{.IndexData.VersionTag}}{{else}}development{{end}}"></script>
<script src="/static/assets/ts-dist/interaction.bundle.js?v={{if .IndexData.VersionTag}}{{.IndexData.VersionTag}}{{else}}development{{end}}"></script>
<link href="/static/assets/css-dist/home.css?v={{if .IndexData.VersionTag}}{{.IndexData.VersionTag}}{{else}}development{{end}}"
rel="stylesheet">
<link rel="stylesheet" href="/static/node_modules/video.js/dist/video-js.min.css">
<link rel="stylesheet" href="/static/node_modules/videojs-seek-buttons/dist/videojs-seek-buttons.css">
<link rel="stylesheet" href="/static/node_modules/@silvermine/videojs-airplay/dist/silvermine-videojs-airplay.css">
<link rel="stylesheet"
href="/static/assets/css-dist/watch.css?v={{if .IndexData.VersionTag}}{{.IndexData.VersionTag}}{{else}}development{{end}}">
{{/* Remove this when using KaTeX in other locations than the chat */}}
{{if $stream.ChatEnabled}}
<link rel="stylesheet" href="/static/node_modules/katex/dist/katex.min.css">
<script defer src="/static/node_modules/katex/dist/katex.js"></script>
<script defer src="/static/node_modules/katex/dist/contrib/auto-render.min.js"></script>
<script defer src="/static/node_modules/katex/dist/contrib/copy-tex.min.js"></script>
{{end}}
</head>
<body x-data="{'streamID': {{$stream.Model.ID}} }" class="w-full">
{{template "header" .IndexData.TUMLiveContext}}
<div class="h-full w-full flex flex-col items-center" x-data="interaction.videoInformationContext({{$stream.ID}});">
<!-- Heading with Lecture and Course Name -->
<div class="flex flex-row items-center pt-2">
<a href="/w/{{$course.Slug}}/{{$stream.Model.ID}}"><h1 class="text-gray-900 dark:text-gray-200 mb-0">{{$stream.Name}}</h1></a>
<span class="text-gray-900 dark:text-gray-200 mx-2">|</span>
<a href="/?year={{$course.Year}}&term={{$course.TeachingTerm}}&slug={{$course.Slug}}&view=3"><h3 class="text-gray-800 dark:text-gray-300">{{$course.Name}}</h3></a>
</div>

<div class="flex flex-row w-full">
<div class="flex flex-col w-full pl-5 pt-3">
<div class="flex flex-row items-center justify-between" style="width: 25vw;">
<div x-init="interaction.periodicUpdateLiveTimeLeft( '{{.Lecture.End}}' );" class="flex flex-row items-center justify-start gap-1">
<div class="pulsing-circle"></div>
<span class="text-2 text-gray-800 dark:text-gray-200 mt-1" id="live-time-remaining">Time left: </span>
</div>
<span class="text-2 text-gray-800 dark:text-gray-200 mt-1" id="time"></span>
</div>
<div class="w-full flex flex-row gap-2">
<div class="flex flex-col h-full" style="width: 25vw">
<!-- Video Player -->
<div style="width: 100%; height: fit-content;">
<video-js id="videoPlayer" class="video-js vjs-default-skin vjs-16-9 mt-1"
preload="auto" muted autoplay
data-setup='{"fluid": true, "liveui": true}'
poster="/public/no_active_stream.jpg">
{{if $stream.PlaylistUrl}}
<source src="{{$stream.PlaylistUrl}}" type="application/x-mpegURL">
{{else}}
<source src="{{$stream.PlaylistUrlPRES}}" type="application/x-mpegURL">
{{end}}
</video-js>
</div>
<div class="rounded-lg bg-gray-50 hover:bg-gray-100 dark:bg-secondary-light hover:dark:bg-gray-600 text-3 text-sm w-full p-2 mt-1">
{{if eq .Lecture.Description ""}}
<span id="description" class="text-wrap">No description available</span>
{{else}}
<span id="description" class="text-wrap">{{.Lecture.Description}}</span>
{{end}}
</div>
<button @click="watch.seekToLive()" class="align-middle mt-1 text-3 hover:text-1 font-medium bg-indigo-600 hover:bg-indigo-500 rounded-2xl w-full">Seek to Live</button>
<button @click="watch.setHighestQuality()" class="dark:bg-blue-800 dark:hover:bg-blue-700 align-middle mt-1 text-3 hover:text-1 font-light rounded-2xl w-full">Switch to highest quality</button>
<div class="w-full flex flex-row gap-1 pt-2">
<button @click="window.location.href='?restart=1'"
title="Issues with the stream? Restart it."
class="bg-indigo-600 text-center md:inline-block block w-full text-white font-bold hover:bg-indigo-500 rounded-lg cursor-pointer py-1">
<i class="fas fa-redo mr-1"></i>Restart
</button>
<button @click="$dispatch('stop')"
title="Already finished? End the stream early."
class="bg-red-600 text-center md:inline-block block w-full text-white hover:bg-red-500 font-bold rounded-lg cursor-pointer py-1">
<i class="fas fa-power-off mr-1"></i>Stop
</button>
</div>

<!-- TODO: Fix this
<div>
<span class="text-gray-200">Student Live activity during lecture</span>
<div class="w-full m-auto" style="min-height: 200px; min-width:200px;">
<canvas id="lectureLiveStats" width="400" height="100" aria-label="Viewer Live stats"
role="img"></canvas>
</div>
</div>-->
</div>


<!-- Video Stats -->
<div
x-data="{ data: { bufferSeconds: 0, videoHeight: 0, videoWidth: 0, bandwidth: 0, mediaRequests: 0, mediaRequestsFailed: 0 } }"
x-on:newvideostats.window="e => { data = e.detail }"
class="important-text-4"
>
<div class="flex justify-between align-middle pb-4">
<h3>Video Stats</h3>
</div>
<div class="flex justify-between align-middle">
<div>
<div>Resolution:</div>
<div>Bandwidth:</div>
</div>
<div class="pr-4">
<div class="text-right" x-text="`${data.videoWidth}x${data.videoHeight}`"></div>
<div class="text-right"
x-text="Math.round(data.bandwidth / 1000000) + ' MBit/s'"></div>
</div>
</div>
<br>
<div>Buffer:</div>
<div class="flex justify-between align-middle">
<div>
<div class="pl-2">> Buffered Time:</div>
<div class="pl-2">> Chunks Requested:</div>
<div class="pl-2">> Requests Failed:</div>
</div>
<div class="pr-4">
<div class="text-right"
x-text="(Math.round(data.bufferSeconds * 100) / 100) + 's'"></div>
<div class="text-right" x-text="data.mediaRequests"></div>
<div class="text-right" x-text="data.mediaRequestsFailed"></div>
</div>
</div>
</div>
</div>
</div>

<!-- Chat -->
{{if and $course.ChatEnabled $stream.ChatEnabled}}
<div
style="height: 80vh; width: 40vw" class="pr-5">
<div class="border dark:border-gray-800 rounded-lg h-full">
{{template "chat-component" .ChatData}}
</div>
</div>
{{end}}

</div>

</div>

<!-- Modal when stopping a stream -->
<div class="inline-block" x-data="{ 'showModal': false }"
@stop.window="showModal=true"
@keydown.escape="showModal = false" x-cloak x-show="showModal">
<section class="flex flex-wrap h-full">
<div class="overflow-auto" x-show="showModal"
:class="{ 'absolute inset-0 z-50 flex items-center justify-center': showModal }">
<div class="dark:bg-secondary-light bg-gray-200 text-3 absolute transform -translate-x-1/2 -translate-y-1/2 left-1/2 top-1/2 w-auto md:max-w-md mx-auto rounded shadow-lg py-4 text-left
px-6"
@click.away="showModal = false">
<!--Title-->
<div class="flex justify-between pb-3">
<p class="text-2 font-bold">Keep the recording after ending the stream?</p>
</div>
<!--Footer-->
<div class="flex justify-start pt-2">
<button class="bg-green-500 inline-block text-center w-24 text-white hover:bg-green-600 dark:hover:bg-green-600 font-bold rounded cursor-pointer p-1 mr-3"
@click="fetch(`/api/stream/${streamID}/end?discard=${false}`).then((response) => {if (response.ok) showModal=false; else throw new Error('Response not ok');}).catch((err) => {
console.log('Some error occurred while ending the stream:', err);
});">
Yes
</button>
<button class="bg-red-500 inline-block text-center w-24 text-white hover:bg-red-600 dark:hover:bg-red-600 font-bold rounded cursor-pointer p-1 mr-3"
@click="fetch(`/api/stream/${streamID}/end?discard=${false}`).then((response) => {if (response.ok) showModal=false; else throw new Error('Response not ok');}).catch((err) => {
console.log('Some error occurred while ending the stream:', err);
});">
No
</button>
<button class="bg-gray-500 inline-block text-center w-24 text-white hover:bg-gray-600 dark:hover:bg-gray-600 font-bold rounded cursor-pointer p-1"
@click="showModal = false">Cancel
</button>
</div>
</div>
</div>
</section>
</div>

<script defer>
watch.initPlayer("videoPlayer", true, false, false, {{.IndexData.TUMLiveContext.User.GetEnabledPlaybackSpeeds}}, {{$stream.LiveNow}}, {{.IndexData.TUMLiveContext.User.GetSeekingTime}});
setTimeout(() => watch.setHighestQuality(), 2000);
watch.periodicCurrentTime("time");
watch.videoStatListener.listen();

admin.loadLectureStats("lecture", "lectureLiveStats", "{{.Lecture.Model.ID}}");
admin.initLectureStatsPage("{{.Lecture.Model.ID}}");
</script>
</body>
</html>
17 changes: 17 additions & 0 deletions web/ts/components/video-information.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions web/ts/entry/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
4 changes: 4 additions & 0 deletions web/ts/interval-updates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function periodicCurrentTime(id: string) {
const time = document.getElementById(id);
setInterval(() => (time.innerHTML = new Date().toLocaleTimeString()), 1000);
}
Loading
Loading