diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 29aac58e0..4eec1b08c 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -452,6 +452,26 @@ func SetChatDisabled(w http.ResponseWriter, r *http.Request) { controllers.WriteSimpleResponse(w, true, "chat disabled status updated") } +// SetExternalActions will set the 3rd party actions for the web interface. +func SetExternalActions(w http.ResponseWriter, r *http.Request) { + type externalActionsRequest struct { + Value []models.ExternalAction `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var actions externalActionsRequest + if err := decoder.Decode(&actions); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values") + return + } + + if err := data.SetExternalActions(actions.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values") + } + + controllers.WriteSimpleResponse(w, true, "external actions update") +} + func requirePOST(w http.ResponseWriter, r *http.Request) bool { if r.Method != controllers.POST { controllers.WriteSimpleResponse(w, false, r.Method+" not supported") diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index fb721dd0c..6d99f001b 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -52,7 +52,8 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { Enabled: data.GetDirectoryEnabled(), InstanceURL: data.GetServerURL(), }, - S3: data.GetS3Config(), + S3: data.GetS3Config(), + ExternalActions: data.GetExternalActions(), } w.Header().Set("Content-Type", "application/json") @@ -63,16 +64,17 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { } type serverConfigAdminResponse struct { - InstanceDetails webConfigResponse `json:"instanceDetails"` - FFmpegPath string `json:"ffmpegPath"` - StreamKey string `json:"streamKey"` - WebServerPort int `json:"webServerPort"` - RTMPServerPort int `json:"rtmpServerPort"` - S3 models.S3 `json:"s3"` - VideoSettings videoSettings `json:"videoSettings"` - LatencyLevel int `json:"latencyLevel"` - YP yp `json:"yp"` - ChatDisabled bool `json:"chatDisabled"` + InstanceDetails webConfigResponse `json:"instanceDetails"` + FFmpegPath string `json:"ffmpegPath"` + StreamKey string `json:"streamKey"` + WebServerPort int `json:"webServerPort"` + RTMPServerPort int `json:"rtmpServerPort"` + S3 models.S3 `json:"s3"` + VideoSettings videoSettings `json:"videoSettings"` + LatencyLevel int `json:"latencyLevel"` + YP yp `json:"yp"` + ChatDisabled bool `json:"chatDisabled"` + ExternalActions []models.ExternalAction `json:"externalActions"` } type videoSettings struct { diff --git a/controllers/config.go b/controllers/config.go index a5016baf0..723681b7f 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -12,16 +12,17 @@ import ( ) type webConfigResponse struct { - Name string `json:"name"` - Summary string `json:"summary"` - Logo string `json:"logo"` - Tags []string `json:"tags"` - Version string `json:"version"` - NSFW bool `json:"nsfw"` - ExtraPageContent string `json:"extraPageContent"` - StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream - SocialHandles []models.SocialHandle `json:"socialHandles"` - ChatDisabled bool `json:"chatDisabled"` + Name string `json:"name"` + Summary string `json:"summary"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` + ChatDisabled bool `json:"chatDisabled"` + ExternalActions []models.ExternalAction `json:"externalActions"` } // GetWebConfig gets the status of the server. @@ -50,6 +51,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) { StreamTitle: data.GetStreamTitle(), SocialHandles: socialHandles, ChatDisabled: data.GetChatDisabled(), + ExternalActions: data.GetExternalActions(), } if err := json.NewEncoder(w).Encode(configuration); err != nil { diff --git a/core/data/config.go b/core/data/config.go index 6a629a58c..9455748ef 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -34,6 +34,7 @@ const s3StorageConfigKey = "s3_storage_config" const videoLatencyLevel = "video_latency_level" const videoStreamOutputVariantsKey = "video_stream_output_variants" const chatDisabledKey = "chat_disabled" +const externalActionsKey = "external_actions" // GetExtraPageBodyContent will return the user-supplied body content. func GetExtraPageBodyContent() string { @@ -424,6 +425,27 @@ func GetChatDisabled() bool { return false } +// GetExternalActions will return the registered external actions. +func GetExternalActions() []models.ExternalAction { + configEntry, err := _datastore.Get(externalActionsKey) + if err != nil { + return []models.ExternalAction{} + } + + var externalActions []models.ExternalAction + if err := configEntry.getObject(&externalActions); err != nil { + return []models.ExternalAction{} + } + + return externalActions +} + +// SetExternalActions will save external actions. +func SetExternalActions(actions []models.ExternalAction) error { + var configEntry = ConfigEntry{Key: externalActionsKey, Value: actions} + return _datastore.Save(configEntry) +} + // VerifySettings will perform a sanity check for specific settings values. func VerifySettings() error { if GetStreamKey() == "" { diff --git a/models/externalAction.go b/models/externalAction.go new file mode 100644 index 000000000..5508e2096 --- /dev/null +++ b/models/externalAction.go @@ -0,0 +1,17 @@ +package models + +// ExternalAction is a link that will open as a 3rd party action. +type ExternalAction struct { + // URL is the URL to load. + URL string `json:"url"` + // Title is the name of this action, displayed in the modal. + Title string `json:"title"` + // Description is the description of this action. + Description string `json:"description"` + // Icon is the optional icon for the button associated with this action. + Icon string `json:"icon"` + // Color is the optional color for the button associated with this action. + Color string `json:"color"` + // OpenExternally states if the action should open a new tab/window instead of an internal modal. + OpenExternally bool `json:"openExternally"` +} diff --git a/router/router.go b/router/router.go index adc3c1f7c..e28c48cb3 100644 --- a/router/router.go +++ b/router/router.go @@ -150,6 +150,7 @@ func Start() error { // Connected clients http.HandleFunc("/api/integrations/clients", middleware.RequireAccessToken(models.ScopeHasAdminAccess, controllers.GetConnectedClients)) + // Logo path http.HandleFunc("/api/admin/config/logo", middleware.RequireAdminAuth(admin.SetLogoPath)) @@ -189,6 +190,9 @@ func Start() error { // reset the YP registration http.HandleFunc("/api/admin/yp/reset", middleware.RequireAdminAuth(admin.ResetYPRegistration)) + // set external action links + http.HandleFunc("/api/admin/config/externalactions", middleware.RequireAdminAuth(admin.SetExternalActions)) + port := config.WebServerPort log.Infof("Web server is listening on port %d.", port) diff --git a/webroot/img/loading.gif b/webroot/img/loading.gif new file mode 100644 index 000000000..2846863ac Binary files /dev/null and b/webroot/img/loading.gif differ diff --git a/webroot/img/video-settings.png b/webroot/img/video-settings.png index d37f489d8..a7a97a051 100644 Binary files a/webroot/img/video-settings.png and b/webroot/img/video-settings.png differ diff --git a/webroot/js/app.js b/webroot/js/app.js index 7d65875d4..47c3e193a 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -13,6 +13,9 @@ import { hasTouchScreen, getOrientation, } from './utils/helpers.js'; +import ExternalActionModal, { + ExternalActionButton, +} from './components/external-action-modal.js'; import { addNewlines, @@ -73,6 +76,8 @@ export default class App extends Component { windowWidth: window.innerWidth, windowHeight: window.innerHeight, orientation: getOrientation(this.hasTouchScreen), + + externalAction: null, }; // timers @@ -96,7 +101,10 @@ export default class App extends Component { this.disableChatInput = this.disableChatInput.bind(this); this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyPressed = this.handleKeyPressed.bind(this); + this.displayExternalAction = this.displayExternalAction.bind(this); + this.closeExternalActionModal = this.closeExternalActionModal.bind(this); // player events this.handlePlayerReady = this.handlePlayerReady.bind(this); @@ -119,6 +127,7 @@ export default class App extends Component { if (this.hasTouchScreen) { window.addEventListener('orientationchange', this.handleWindowResize); } + window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keypress', this.handleKeyPressed); this.player = new OwncastPlayer(); this.player.setupPlayerCallbacks({ @@ -140,6 +149,7 @@ export default class App extends Component { window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('focus', this.handleWindowFocus); + window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keypress', this.handleKeyPressed); if (this.hasTouchScreen) { window.removeEventListener('orientationchange', this.handleWindowResize); @@ -386,6 +396,12 @@ export default class App extends Component { } } + handleKeyDown(e) { + if (e.code === 'Escape' && this.state.externalAction !== null) { + this.closeExternalActionModal(); + } + } + handleKeyPressed(e) { if ( e.code === 'Space' && @@ -396,6 +412,35 @@ export default class App extends Component { } } + displayExternalAction(index) { + const { configData, username } = this.state; + const action = configData.externalActions[index]; + if (!action) { + return; + } + const { url: actionUrl, openExternally } = action || {}; + let url = new URL(actionUrl); + // Append url and username to params so the link knows where we came from and who we are. + url.searchParams.append('username', username); + url.searchParams.append('instance', window.location); + + if (openExternally) { + var win = window.open(url.toString(), '_blank'); + win.focus(); + return; + } + + this.setState({ + externalAction: action, + }); + } + + closeExternalActionModal() { + this.setState({ + externalAction: null, + }); + } + render(props, state) { const { chatInputEnabled, @@ -413,6 +458,7 @@ export default class App extends Component { websocket, windowHeight, windowWidth, + externalAction, } = state; const { @@ -424,23 +470,12 @@ export default class App extends Component { name, extraPageContent, chatDisabled, + externalActions, } = configData; const bgUserLogo = { backgroundImage: `url(${logo})` }; - const tagList = - tags !== null && tags.length > 0 - ? tags.map( - (tag, index) => html` -