diff --git a/api/router.go b/api/router.go index 8eabec6b5..4c0651c04 100755 --- a/api/router.go +++ b/api/router.go @@ -21,6 +21,7 @@ func ConfigRealtimeRouter(router *gin.RouterGroup) { // Register Channels RegisterLiveUpdateRealtimeChannel() RegisterLiveRunnerPageUpdateRealtimeChannel(daoWrapper) + RegisterReactionUpdateRealtimeChannel() RegisterRealtimeChatChannel() } diff --git a/api/stream.go b/api/stream.go index 4f880274e..2552dec98 100644 --- a/api/stream.go +++ b/api/stream.go @@ -31,6 +31,7 @@ const ( func configGinStreamRestRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) { routes := streamRoutes{daoWrapper} + reactionRoutes := StreamReactionRoutes{daoWrapper} stream := router.Group("/api/stream") { @@ -46,6 +47,9 @@ func configGinStreamRestRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) { streamById.GET("/playlist", routes.getStreamPlaylist) + streamById.POST("/reaction", reactionRoutes.addReaction) + streamById.GET("/reaction/allowed", reactionRoutes.allowedReactions) + thumbs := streamById.Group("/thumbs") { thumbs.GET(":fid", routes.getThumbs) diff --git a/api/stream_reactions.go b/api/stream_reactions.go new file mode 100644 index 000000000..c09750f56 --- /dev/null +++ b/api/stream_reactions.go @@ -0,0 +1,356 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + "sync" + "time" + + "github.com/TUM-Dev/gocast/dao" + "github.com/TUM-Dev/gocast/model" + "github.com/TUM-Dev/gocast/tools" + "github.com/TUM-Dev/gocast/tools/realtime" + "github.com/getsentry/sentry-go" + "github.com/gin-gonic/gin" +) + +type StreamReactionRoutes struct { + dao.DaoWrapper +} + +// TODO: This can be modified to allow different reactions for different streams +func (r StreamReactionRoutes) allowedReactions(c *gin.Context) { + c.JSON(http.StatusOK, tools.Cfg.AllowedReactions) +} + +func (r StreamReactionRoutes) addReaction(c *gin.Context) { + tumLiveContext := c.MustGet("TUMLiveContext").(tools.TUMLiveContext) + user := tumLiveContext.User + stream := tumLiveContext.Stream + + if stream == nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusNotFound, + CustomMessage: "stream not found", + }) + return + } + + course, err := r.DaoWrapper.CoursesDao.GetCourseById(c, stream.CourseID) + + if user == nil || err != nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusInternalServerError, + CustomMessage: "user or course not found", + }) + return + } + + if !user.IsEligibleToWatchCourse(course) { + _ = c.Error(tools.RequestError{ + Status: http.StatusForbidden, + CustomMessage: "user not eligible to watch course", + }) + return + } + + type reactionRequest struct { + Reaction string `json:"reaction"` + } + + var reaction reactionRequest + if err := c.ShouldBindJSON(&reaction); err != nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusBadRequest, + CustomMessage: "can not bind body", + Err: err, + }) + return + } + + // This can be modified to allow different reactions for different streams + if !slices.Contains(tools.Cfg.AllowedReactions, reaction.Reaction) { + _ = c.Error(tools.RequestError{ + Status: http.StatusBadRequest, + CustomMessage: "reaction not allowed", + }) + return + } + + lastReaction, _ := r.DaoWrapper.StreamReactionDao.GetLastReactionOfUser(c, user.ID) + // This contains the cooldown logic, to change this value change the time.Duration(10) to the desired cooldown time + if lastReaction.Reaction != "" && lastReaction.CreatedAt.Add(time.Duration(10)*time.Second).After(time.Now()) { + _ = c.Error(tools.RequestError{ + Status: http.StatusTooManyRequests, + CustomMessage: "cooldown not over", + }) + return + } + + reactionObj := model.StreamReaction{ + Reaction: reaction.Reaction, + StreamID: stream.ID, + UserID: user.ID, + } + + err = r.DaoWrapper.StreamReactionDao.Create(c, &reactionObj) + if err != nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusInternalServerError, + CustomMessage: "can not create reaction", + Err: err, + }) + return + } + NotifyAdminsOnReaction(stream.ID, reaction.Reaction) + c.JSON(http.StatusOK, "") +} + +// The part below is used for Realtime Connection to the client + +const ( + ReactionUpdateRoomName = "reaction-update" +) + +var ( + liveReactionListenerMutex sync.RWMutex + liveReactionListener = map[uint]*liveReactionAdminSessionsWrapper{} +) + +type liveReactionAdminSessionsWrapper struct { + sessions []*realtime.Context + stream uint +} + +func RegisterReactionUpdateRealtimeChannel() { + RealtimeInstance.RegisterChannel(ReactionUpdateRoomName, realtime.ChannelHandlers{ + OnSubscribe: reactionUpdateOnSubscribe, + OnUnsubscribe: reactionUpdateOnUnsubscribe, + OnMessage: reactionUpdateSetStream, + }) + + go func() { + // Notify admins every 5 seconds + logger.Info("Starting periodic notification of reaction percentages") + for { + time.Sleep(5 * time.Second) + NotifyAdminsOnReactionPercentages(context.Background()) + } + }() +} + +func reactionUpdateOnUnsubscribe(psc *realtime.Context) { + logger.Debug("Unsubscribing from reaction Update") + ctx, _ := psc.Client.Get("ctx") // get gin context + foundContext, exists := ctx.(*gin.Context).Get("TUMLiveContext") + if !exists { + sentry.CaptureException(errors.New("context should exist but doesn't")) + return + } + + tumLiveContext := foundContext.(tools.TUMLiveContext) + + var userId uint + if tumLiveContext.User != nil { + userId = tumLiveContext.User.ID + } + + liveReactionListenerMutex.Lock() + defer liveReactionListenerMutex.Unlock() + var newSessions []*realtime.Context + for _, session := range liveReactionListener[userId].sessions { + if session != psc { + newSessions = append(newSessions, session) + } + } + if len(newSessions) == 0 { + delete(liveReactionListener, userId) + } else { + liveReactionListener[userId].sessions = newSessions + } + logger.Debug("Successfully unsubscribed from reaction Update") +} + +func reactionUpdateOnSubscribe(psc *realtime.Context) { + ctx, _ := psc.Client.Get("ctx") // get gin context + + foundContext, exists := ctx.(*gin.Context).Get("TUMLiveContext") + if !exists { + sentry.CaptureException(errors.New("context should exist but doesn't")) + return + } + + tumLiveContext := foundContext.(tools.TUMLiveContext) + + var userId uint + var err error + + if tumLiveContext.User != nil { + userId = tumLiveContext.User.ID + } else { + logger.Error("could not fetch public courses", "err", err) + return + + } + + liveReactionListenerMutex.Lock() + defer liveReactionListenerMutex.Unlock() + existing := liveReactionListener[userId] + if existing != nil { + liveReactionListener[userId] = &liveReactionAdminSessionsWrapper{append(existing.sessions, psc), liveReactionListener[userId].stream} + } else { + liveReactionListener[userId] = &liveReactionAdminSessionsWrapper{[]*realtime.Context{psc}, 0} + } +} + +func reactionUpdateSetStream(psc *realtime.Context, message *realtime.Message) { + logger.Info("reactionUpdateSetStream", "message", string(message.Payload)) + ctx, _ := psc.Client.Get("ctx") // get gin context + + foundContext, exists := ctx.(*gin.Context).Get("TUMLiveContext") + if !exists { + sentry.CaptureException(errors.New("context should exist but doesn't")) + return + } + + tumLiveContext := foundContext.(tools.TUMLiveContext) + + var userId uint + var err error + + if tumLiveContext.User != nil { + userId = tumLiveContext.User.ID + } else { + logger.Error("could not get user from request", "err", err) + return + } + + type Message struct { + StreamID string `json:"streamId"` + } + + var messageObj Message + err = json.Unmarshal(message.Payload, &messageObj) + if err != nil { + logger.Error("could not unmarshal message", "err", err) + return + } + + stream, err := daoWrapper.StreamsDao.GetStreamByID(context.TODO(), messageObj.StreamID) + if err != nil { + logger.Error("Cant get stream by id", "err", err) + return + } + course, err := daoWrapper.CoursesDao.GetCourseById(context.TODO(), stream.CourseID) + if err != nil { + logger.Error("Cant get course by id", "err", err) + return + } + if !tumLiveContext.User.IsAdminOfCourse(course) { + logger.Error("User is not admin of course") + reactionUpdateOnUnsubscribe(psc) + return + } + + liveReactionListenerMutex.Lock() + defer liveReactionListenerMutex.Unlock() + if liveReactionListener[userId] != nil { + uId, err := strconv.Atoi(messageObj.StreamID) + if err != nil { + logger.Error("could not convert streamID to int", "err", err) + return + } + liveReactionListener[userId].stream = uint(uId) + } else { + logger.Error("User has no live reaction listener") + } +} + +func NotifyAdminsOnReaction(streamID uint, reaction string) { + liveReactionListenerMutex.Lock() + defer liveReactionListenerMutex.Unlock() + reactionStruct := struct { + Reaction string `json:"reaction"` + }{ + Reaction: reaction, + } + reactionMarshaled, err := json.Marshal(reactionStruct) + if err != nil { + logger.Error("could not marshal reaction", "err", err) + return + } + for _, session := range liveReactionListener { + if session.stream == streamID { + for _, s := range session.sessions { + err := s.Send(reactionMarshaled) + if err != nil { + logger.Error("can't write reaction to session", "err", err) + } + } + } + } +} + +func NotifyAdminsOnReactionPercentages(context context.Context) { + liveReactionListenerMutex.Lock() + defer liveReactionListenerMutex.Unlock() + streams := make([]uint, 0) + for _, session := range liveReactionListener { + streams = append(streams, session.stream) + } + liveReactionListenerMutex.Unlock() + + streamReactionPercentages := map[uint]map[string]float64{} + + for _, stream := range streams { + reactionsRaw, err := daoWrapper.StreamReactionDao.GetByStreamWithinMinutes(context, stream, 2) // TODO: Make this variable for the lecturer + if err != nil { + logger.Error("could not get reactions for stream", "stream", stream, "err", err) + return + } + + reactions := make(map[string]int) + for _, reaction := range reactionsRaw { + reactions[reaction.Reaction]++ + } + + totalReactions := 0 + for _, count := range reactions { + totalReactions += count + } + if totalReactions == 0 { + // logger.Debug("no reactions for stream", "stream", stream) + continue + } + + streamReactionPercentages[stream] = make(map[string]float64) + for reaction, count := range reactions { + streamReactionPercentages[stream][reaction] = float64(count) / float64(totalReactions) + } + } + + // Send the percentages to the admin sessions + liveReactionListenerMutex.Lock() + + for _, session := range liveReactionListener { + if session.stream == 0 { + continue + } + reactionPercentages := streamReactionPercentages[session.stream] + reactionPercentagesMarshaled, err := json.Marshal(reactionPercentages) + if err != nil { + logger.Error("could not marshal reaction percentages", "err", err) + return + } + for _, s := range session.sessions { + err := s.Send([]byte("{\"percentages\": " + string(reactionPercentagesMarshaled) + "}")) + if err != nil { + logger.Error("can't write reaction percentages to session", "err", err) + } + } + } +} diff --git a/cmd/tumlive/tumlive.go b/cmd/tumlive/tumlive.go index 1a014cd4b..24989c6c4 100755 --- a/cmd/tumlive/tumlive.go +++ b/cmd/tumlive/tumlive.go @@ -212,6 +212,7 @@ func main() { &model.Subtitles{}, &model.TranscodingFailure{}, &model.Email{}, + &model.StreamReaction{}, &model.Runner{}, ) if err != nil { diff --git a/config.yaml b/config.yaml index d89a8bdcf..e02c8dfb0 100644 --- a/config.yaml +++ b/config.yaml @@ -104,3 +104,8 @@ meili: vodURLTemplate: https://stream.lrz.de/vod/_definst_/mp4:tum/RBG/%s.mp4/playlist.m3u8 canonicalURL: https://tum.live rtmpProxyURL: https://proxy.example.com +allowedReactions: + - 😊 + - 👍 + - 👎 + - 😢 diff --git a/dao/dao_base.go b/dao/dao_base.go index 6f1888853..35f90ba8c 100644 --- a/dao/dao_base.go +++ b/dao/dao_base.go @@ -36,6 +36,7 @@ type DaoWrapper struct { SubtitlesDao TranscodingFailureDao EmailDao + StreamReactionDao RunnerDao RunnerDao } @@ -64,6 +65,7 @@ func NewDaoWrapper() DaoWrapper { SubtitlesDao: NewSubtitlesDao(), TranscodingFailureDao: NewTranscodingFailureDao(), EmailDao: NewEmailDao(), + StreamReactionDao: NewStreamReactionDao(), RunnerDao: NewRunnerDao(), } } diff --git a/dao/stream-reaction.go b/dao/stream-reaction.go new file mode 100644 index 000000000..ec73b81b3 --- /dev/null +++ b/dao/stream-reaction.go @@ -0,0 +1,90 @@ +package dao + +import ( + "context" + "time" + + "github.com/TUM-Dev/gocast/model" + "gorm.io/gorm" +) + +//go:generate mockgen -source=streamReaction.go -destination ../mock_dao/streamReaction.go + +type StreamReactionDao interface { + // Get StreamReaction by ID + Get(context.Context, uint) (model.StreamReaction, error) + + // Create a new StreamReaction for the database + Create(context.Context, *model.StreamReaction) error + + // Delete a StreamReaction by id. + Delete(context.Context, uint) error + + GetByStream(context.Context, uint) ([]model.StreamReaction, error) + + GetByStreamWithinMinutes(context.Context, uint, uint) ([]model.StreamReaction, error) + + GetNumbersOfReactions(context.Context, uint) (map[string]int, error) + + GetLastReactionOfUser(context.Context, uint) (model.StreamReaction, error) +} + +type streamReactionDao struct { + db *gorm.DB +} + +type reactionCount struct { + Reaction string `json:"reaction"` + Count int `json:"count"` +} + +func NewStreamReactionDao() StreamReactionDao { + return streamReactionDao{db: DB} +} + +// Get a StreamReaction by id. +func (d streamReactionDao) Get(c context.Context, id uint) (res model.StreamReaction, err error) { + return res, d.db.WithContext(c).First(&res, id).Error +} + +// Create a StreamReaction. +func (d streamReactionDao) Create(c context.Context, it *model.StreamReaction) error { + return d.db.WithContext(c).Create(it).Error +} + +// Delete a StreamReaction by id. +func (d streamReactionDao) Delete(c context.Context, id uint) error { + return d.db.WithContext(c).Delete(&model.StreamReaction{}, id).Error +} + +// GetByStream gets a StreamReaction by stream. +func (d streamReactionDao) GetByStream(c context.Context, streamID uint) (res []model.StreamReaction, err error) { + return res, d.db.WithContext(c).Where("stream_id = ?", streamID).Find(&res).Error +} + +// GetByStream gets a StreamReaction by stream within the last ... minutes. +func (d streamReactionDao) GetByStreamWithinMinutes(c context.Context, streamID uint, minutes uint) (res []model.StreamReaction, err error) { + time_specified := time.Now().Add(-time.Duration(minutes) * time.Minute) + return res, d.db.WithContext(c).Where("stream_id = ? AND created_at > ?", streamID, time_specified.String()).Find(&res).Error +} + +// GetNumbersOfReactions gets the number of reactions grouped by reactions for a stream. +func (d streamReactionDao) GetNumbersOfReactions(c context.Context, streamID uint) (map[string]int, error) { + var reactionCounts []reactionCount + err := d.db.WithContext(c).Model(&model.StreamReaction{}).Where("stream_id = ?", streamID).Group("reaction").Select("reaction, count(reaction) as count").Scan(&reactionCounts).Error + if err != nil { + return nil, err + } + + reactionMap := make(map[string]int) + for _, rc := range reactionCounts { + reactionMap[rc.Reaction] = rc.Count + } + + return reactionMap, err +} + +// GetLastReactionOfUser gets the last reaction of a user. +func (d streamReactionDao) GetLastReactionOfUser(c context.Context, userID uint) (res model.StreamReaction, err error) { + return res, d.db.WithContext(c).Where("user_id = ?", userID).Last(&res).Error +} diff --git a/mock_dao/stream-reaction.go b/mock_dao/stream-reaction.go new file mode 100644 index 000000000..1ca568889 --- /dev/null +++ b/mock_dao/stream-reaction.go @@ -0,0 +1,109 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: streamReaction.go + +// Package mock_dao is a generated GoMock package. +package mock_dao + +import ( + context "context" + reflect "reflect" + + model "github.com/TUM-Dev/gocast/model" + "go.uber.org/mock/gomock" +) + +// MockStreamReactionDao is a mock of StreamReactionDao interface. +type MockStreamReactionDao struct { + ctrl *gomock.Controller + recorder *MockStreamReactionDaoMockRecorder +} + +// MockStreamReactionDaoMockRecorder is the mock recorder for MockStreamReactionDao. +type MockStreamReactionDaoMockRecorder struct { + mock *MockStreamReactionDao +} + +// NewMockStreamReactionDao creates a new mock instance. +func NewMockStreamReactionDao(ctrl *gomock.Controller) *MockStreamReactionDao { + mock := &MockStreamReactionDao{ctrl: ctrl} + mock.recorder = &MockStreamReactionDaoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStreamReactionDao) EXPECT() *MockStreamReactionDaoMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockStreamReactionDao) Create(arg0 context.Context, arg1 *model.StreamReaction) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockStreamReactionDaoMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockStreamReactionDao)(nil).Create), arg0, arg1) +} + +// Delete mocks base method. +func (m *MockStreamReactionDao) Delete(arg0 context.Context, arg1 uint) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockStreamReactionDaoMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStreamReactionDao)(nil).Delete), arg0, arg1) +} + +// Get mocks base method. +func (m *MockStreamReactionDao) Get(arg0 context.Context, arg1 uint) (model.StreamReaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(model.StreamReaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStreamReactionDaoMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStreamReactionDao)(nil).Get), arg0, arg1) +} + +// GetByStream mocks base method. +func (m *MockStreamReactionDao) GetByStream(arg0 context.Context, arg1 uint) ([]model.StreamReaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByStream", arg0, arg1) + ret0, _ := ret[0].([]model.StreamReaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByStream indicates an expected call of GetByStream. +func (mr *MockStreamReactionDaoMockRecorder) GetByStream(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByStream", reflect.TypeOf((*MockStreamReactionDao)(nil).GetByStream), arg0, arg1) +} + +// GetNumbersOfReactions mocks base method. +func (m *MockStreamReactionDao) GetNumbersOfReactions(arg0 context.Context, arg1 uint) (map[string]int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNumbersOfReactions", arg0, arg1) + ret0, _ := ret[0].(map[string]int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNumbersOfReactions indicates an expected call of GetNumbersOfReactions. +func (mr *MockStreamReactionDaoMockRecorder) GetNumbersOfReactions(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNumbersOfReactions", reflect.TypeOf((*MockStreamReactionDao)(nil).GetNumbersOfReactions), arg0, arg1) +} diff --git a/model/stream-reaction.go b/model/stream-reaction.go new file mode 100644 index 000000000..a5e876a2b --- /dev/null +++ b/model/stream-reaction.go @@ -0,0 +1,18 @@ +package model + +import "gorm.io/gorm" + +// StreamReaction represents Reactions of users to a Stream. +type StreamReaction struct { + gorm.Model + + Reaction string `gorm:"not null" json:"reaction"` + StreamID uint `gorm:"not null" json:"streamID"` + UserID uint `gorm:"not null" json:"userID"` + // Name string `gorm:"column:name;type:text;not null;default:'unnamed'"` +} + +// TableName returns the name of the table for the StreamReaction model in the database. +func (*StreamReaction) TableName() string { + return "stream_reaction" +} diff --git a/tools/config.go b/tools/config.go index 77d0c7ab3..3561e03b8 100644 --- a/tools/config.go +++ b/tools/config.go @@ -95,6 +95,11 @@ func initConfig() { if os.Getenv("DBHOST") != "" { Cfg.Db.Host = os.Getenv("DBHOST") } + if len(Cfg.AllowedReactions) > 0 { + logger.Debug("Allowed reactions", "reactions", Cfg.AllowedReactions) + } else { + logger.Warn("No allowed reactions configured") + } } type Config struct { @@ -171,10 +176,11 @@ type Config struct { Host string `yaml:"host"` ApiKey string `yaml:"apiKey"` } `yaml:"meili"` - VodURLTemplate string `yaml:"vodURLTemplate"` - CanonicalURL string `yaml:"canonicalURL"` - WikiURL string `yaml:"wikiURL"` - RtmpProxyURL string `yaml:"rtmpProxyURL"` + VodURLTemplate string `yaml:"vodURLTemplate"` + CanonicalURL string `yaml:"canonicalURL"` + WikiURL string `yaml:"wikiURL"` + RtmpProxyURL string `yaml:"rtmpProxyURL"` + AllowedReactions []string `yaml:"allowedReactions"` } type MailConfig struct { diff --git a/web/template/admin/lecture-live-management.gohtml b/web/template/admin/lecture-live-management.gohtml index 76eead59d..172ce5719 100644 --- a/web/template/admin/lecture-live-management.gohtml +++ b/web/template/admin/lecture-live-management.gohtml @@ -29,7 +29,7 @@ {{template "header" .IndexData.TUMLiveContext}} -
+

{{$stream.Name}}

@@ -38,7 +38,7 @@
-
+
@@ -46,7 +46,7 @@
-
+
@@ -133,6 +133,23 @@
+
+
+ + + +
+
+ + + {{if and $course.ChatEnabled $stream.ChatEnabled}}
+{{end}} \ No newline at end of file diff --git a/web/template/watch.gohtml b/web/template/watch.gohtml index a85eaee8e..b8aecb2a4 100644 --- a/web/template/watch.gohtml +++ b/web/template/watch.gohtml @@ -478,7 +478,7 @@
-
+
{{if or (.IndexData.TUMLiveContext.User.IsAdminOfCourse .IndexData.TUMLiveContext.Course) .IndexData.IsAdmin}}
+ + {{if .IndexData.TUMLiveContext.Stream.LiveNow }} + + + + {{ end }} +
{{end}} @@ -518,15 +527,28 @@ {{template "actions" .}} - - {{if and $course.ChatEnabled $stream.ChatEnabled}} - - {{end}} + + + {{if and $course.ChatEnabled $stream.ChatEnabled}} + + {{end}} +
+ +
+
+ {{template "stream-reactions" $stream}} +
+
{{ if not $stream.LiveNow }} diff --git a/web/ts/api/stream-reactions.ts b/web/ts/api/stream-reactions.ts new file mode 100644 index 000000000..d2e0bae5c --- /dev/null +++ b/web/ts/api/stream-reactions.ts @@ -0,0 +1,38 @@ +import { getData, postData } from "../global"; +import { get } from "../utilities/fetch-wrappers"; +import { Realtime, RealtimeMessageTypes } from "../socket"; + +// Function to add a reaction to a stream +export function addReaction(reaction: string, streamID: number) { + return postData(`/api/stream/${streamID}/reaction`, { reaction: reaction }); +} + +// Function to get all possible reactions for a stream +export function getAllowedReactions(streamID: number): Promise { + return get(`/api/stream/${streamID}/reaction/allowed`).then((data) => { + return data; + }); +} + +export const liveReactionListener = { + async init(streamId: string) { + await Realtime.get().subscribeChannel("reaction-update", this.handle); + await Realtime.get().send("reaction-update", { + type: RealtimeMessageTypes.RealtimeMessageTypeChannelMessage, + payload: { streamID: streamId }, + }); + }, + + handle(payload: object) { + if (payload["reaction"]) { + // TODO: Handle multiple parallel reactions + window.dispatchEvent(new CustomEvent("reactionupdate", { detail: { data: payload } })); + } else if (payload["percentages"]) { + window.dispatchEvent( + new CustomEvent("reactionupdatepercentages", { detail: { data: payload["percentages"] } }), + ); + } else { + console.log(payload); + } + }, +}; diff --git a/web/ts/entry/video.ts b/web/ts/entry/video.ts index 14cfca3a9..a5d59fa9e 100644 --- a/web/ts/entry/video.ts +++ b/web/ts/entry/video.ts @@ -9,3 +9,4 @@ 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"; +export * from "../api/stream-reactions"; diff --git a/web/ts/watch.ts b/web/ts/watch.ts index 61d9c6d6e..650832823 100644 --- a/web/ts/watch.ts +++ b/web/ts/watch.ts @@ -1,5 +1,6 @@ import { getPlayers } from "./TUMLiveVjs"; import { copyToClipboard, Time } from "./global"; +import { seekbarOverlay } from "./seekbar-overlay"; export enum SidebarState { Hidden = "hidden",