Skip to content

Enh/live admin view #1564

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

Open
wants to merge 24 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
47 changes: 47 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,46 @@ 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])
// TODO: Add restart functionality
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 +431,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
195 changes: 195 additions & 0 deletions web/template/admin/lecture-live-management.gohtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<!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>

<!-- TODO: Add live view count and live view history -->

<!-- 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(showModal=false)">
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=${true}`).then(showModal=false)">
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