diff --git a/core/stats.go b/core/stats.go index 3bf341479..a793f66e0 100644 --- a/core/stats.go +++ b/core/stats.go @@ -68,7 +68,7 @@ func IsStreamConnected() bool { // Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available. // So account for that with an artificial buffer of four segments. - timeSinceLastConnected := time.Since(_stats.LastConnectTime).Seconds() + timeSinceLastConnected := time.Since(_stats.LastConnectTime.Time).Seconds() if timeSinceLastConnected < float64(config.Config.GetVideoSegmentSecondsLength()*4.0) { return false } diff --git a/core/status.go b/core/status.go index 2eaebb15b..56ec3c468 100644 --- a/core/status.go +++ b/core/status.go @@ -6,6 +6,7 @@ import ( "github.com/gabek/owncast/config" "github.com/gabek/owncast/core/ffmpeg" "github.com/gabek/owncast/models" + "github.com/gabek/owncast/utils" ) //GetStatus gets the status of the system @@ -27,9 +28,9 @@ func GetStatus() models.Status { //SetStreamAsConnected sets the stream as connected func SetStreamAsConnected() { _stats.StreamConnected = true - _stats.LastConnectTime = time.Now() + _stats.LastConnectTime = utils.NullTime{time.Now(), true} - timeSinceDisconnect := time.Since(_stats.LastDisconnectTime).Minutes() + timeSinceDisconnect := time.Since(_stats.LastDisconnectTime.Time).Minutes() if timeSinceDisconnect > 15 { _stats.SessionMaxViewerCount = 0 } @@ -45,7 +46,7 @@ func SetStreamAsConnected() { //SetStreamAsDisconnected sets the stream as disconnected func SetStreamAsDisconnected() { _stats.StreamConnected = false - _stats.LastDisconnectTime = time.Now() + _stats.LastDisconnectTime = utils.NullTime{time.Now(), true} ffmpeg.ShowStreamOfflineState() } diff --git a/models/stats.go b/models/stats.go index 1b6f188f0..da6723e66 100644 --- a/models/stats.go +++ b/models/stats.go @@ -2,15 +2,17 @@ package models import ( "time" + + "github.com/gabek/owncast/utils" ) //Stats holds the stats for the system type Stats struct { - SessionMaxViewerCount int `json:"sessionMaxViewerCount"` - OverallMaxViewerCount int `json:"overallMaxViewerCount"` - LastDisconnectTime time.Time `json:"lastDisconnectTime"` + SessionMaxViewerCount int `json:"sessionMaxViewerCount"` + OverallMaxViewerCount int `json:"overallMaxViewerCount"` + LastDisconnectTime utils.NullTime `json:"lastDisconnectTime"` StreamConnected bool `json:"-"` - LastConnectTime time.Time `json:"-"` + LastConnectTime utils.NullTime `json:"-"` Clients map[string]time.Time `json:"-"` } diff --git a/models/status.go b/models/status.go index f81d747fe..053f337be 100644 --- a/models/status.go +++ b/models/status.go @@ -1,8 +1,6 @@ package models -import ( - "time" -) +import "github.com/gabek/owncast/utils" //Status represents the status of the system type Status struct { @@ -11,6 +9,6 @@ type Status struct { OverallMaxViewerCount int `json:"overallMaxViewerCount"` SessionMaxViewerCount int `json:"sessionMaxViewerCount"` - LastConnectTime time.Time `json:"lastConnectTime"` - LastDisconnectTime time.Time `json:"lastDisconnectTime"` + LastConnectTime utils.NullTime `json:"lastConnectTime"` + LastDisconnectTime utils.NullTime `json:"lastDisconnectTime"` } diff --git a/utils/nulltime.go b/utils/nulltime.go new file mode 100644 index 000000000..e5ec931fc --- /dev/null +++ b/utils/nulltime.go @@ -0,0 +1,34 @@ +package utils + +import ( + "database/sql/driver" + "fmt" + "time" +) + +type NullTime struct { + Time time.Time + Valid bool // Valid is true if Time is not NULL +} + +// Scan implements the Scanner interface. +func (nt *NullTime) Scan(value interface{}) error { + nt.Time, nt.Valid = value.(time.Time) + return nil +} + +// Value implements the driver Valuer interface. +func (nt NullTime) Value() (driver.Value, error) { + if !nt.Valid { + return nil, nil + } + return nt.Time, nil +} + +func (nt NullTime) MarshalJSON() ([]byte, error) { + if !nt.Valid { + return []byte("null"), nil + } + val := fmt.Sprintf("\"%s\"", nt.Time.Format(time.RFC3339)) + return []byte(val), nil +} diff --git a/webroot/index.html b/webroot/index.html index 4d0845695..aaf48f3fb 100644 --- a/webroot/index.html +++ b/webroot/index.html @@ -59,7 +59,7 @@ -
+
- - -
-
+
Chat
-
-
- - -
diff --git a/webroot/js/app.js b/webroot/js/app.js index 333979ba8..d690a26e8 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -1,7 +1,6 @@ class Owncast { constructor() { this.player; - this.streamStatus = null; this.websocket = null; this.configData; @@ -14,10 +13,10 @@ class Owncast { this.offlineTimer = null; this.statusTimer = null; this.disableChatTimer = null; + this.streamDurationTimer = null; // misc - this.streamIsOnline = false; - this.lastDisconnectTime = null; + this.streamStatus = null; Vue.filter('plural', pluralize); @@ -36,6 +35,7 @@ class Owncast { this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this); this.handlePlayerEnded = this.handlePlayerEnded.bind(this); this.handlePlayerError = this.handlePlayerError.bind(this); + this.setCurrentStreamDuration = this.setCurrentStreamDuration.bind(this); } init() { @@ -45,7 +45,7 @@ class Owncast { this.vueApp = new Vue({ el: '#app-container', data: { - isOnline: false, + playerOn: false, messages: [], overallMaxViewerCount: 0, sessionMaxViewerCount: 0, @@ -201,8 +201,32 @@ class Owncast { }); }; + // fetch chat history + getChatHistory() { + fetch(URL_CHAT_HISTORY) + .then(response => { + if (!response.ok) { + throw new Error(`Network response was not ok ${response.ok}`); + } + return response.json(); + }) + .then(data => { + const formattedMessages = data.map(function (message) { + return new Message(message); + }) + this.vueApp.messages = formattedMessages.concat(this.vueApp.messages); + }) + .catch(error => { + this.handleNetworkingError(`Fetch getChatHistory: ${error}`); + }); + } + + // handle UI things from stream status result - updateStreamStatus(status) { + updateStreamStatus(status = {}) { + if (!status) { + return; + } // update UI this.vueApp.streamStatus = status.online ? MESSAGE_ONLINE : MESSAGE_OFFLINE; this.vueApp.viewerCount = status.viewerCount; @@ -211,37 +235,56 @@ class Owncast { this.lastDisconnectTime = status.lastDisconnectTime; - if (status.online && !this.streamIsOnline) { - // stream has just come online. - this.handleOnlineMode(); - } else if (!status.online && !this.streamStatus) { - // stream has just gone offline. + if (!this.streamStatus) { // display offline mode the first time we get status, and it's offline. - this.handleOfflineMode(); + if (!status.online) { + this.handleOfflineMode(); + } else { + this.handleOnlineMode(); + } + } else { + if (status.online && !this.streamStatus.online) { + // stream has just come online. + this.handleOnlineMode(); + } else if (!status.online && this.streamStatus.online) { + // stream has just flipped offline. + this.handleOfflineMode(); + } } + // keep a local copy + this.streamStatus = status; + if (status.online) { // only do this if video is paused, so no unnecessary img fetches if (this.player.vjsPlayer && this.player.vjsPlayer.paused()) { this.player.setPoster(); } } - - this.streamStatus = status; }; + // update vueApp.streamStatus text when online + setCurrentStreamDuration() { + // Default to something + let streamDurationString = ''; + + if (this.streamStatus.lastConnectTime) { + const diff = (Date.now() - Date.parse(this.streamStatus.lastConnectTime)) / 1000; + streamDurationString = secondsToHMMSS(diff); + } + this.vueApp.streamStatus = `${MESSAGE_ONLINE} ${streamDurationString}.` + } + handleNetworkingError(error) { console.log(`>>> App Error: ${error}`) }; - // basically hide video and show underlying "poster" + // stop status timer and disable chat after some time. handleOfflineMode() { - this.streamIsOnline = false; - this.vueApp.isOnline = false; + clearInterval(this.streamDurationTimer); this.vueApp.streamStatus = MESSAGE_OFFLINE; - - if (this.lastDisconnectTime) { - const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime)); + if (this.streamStatus) { + const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.streamStatus.lastDisconnectTime)); const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime; this.disableChatTimer = setTimeout(this.messagingInterface.disableChat, countdown); } @@ -249,14 +292,16 @@ class Owncast { // play video! handleOnlineMode() { - this.streamIsOnline = true; - this.vueApp.isOnline = true; + this.vueApp.playerOn = true; this.vueApp.streamStatus = MESSAGE_ONLINE; this.player.startPlayer(); clearTimeout(this.disableChatTimer); this.disableChatTimer = null; this.messagingInterface.enableChat(); + + this.streamDurationTimer = + setInterval(this.setCurrentStreamDuration, TIMER_STREAM_DURATION_COUNTER); } // when videojs player is ready, start polling for stream @@ -271,28 +316,15 @@ class Owncast { }; + // likely called some time after stream status has gone offline. + // basically hide video and show underlying "poster" handlePlayerEnded() { - // do something? - this.handleOfflineMode(); + this.vueApp.playerOn = false; }; handlePlayerError() { // do something? this.handleOfflineMode(); - // stop timers? + this.handlePlayerEnded(); }; - - async getChatHistory() { - const url = "/chat"; - const response = await fetch(url); - const data = await response.json(); - const messages = data.map(function (message) { - return new Message(message); - }) - this.setChatHistory(messages); - } - - setChatHistory(messages) { - this.vueApp.messages = messages.concat(this.vueApp.messages); - } }; diff --git a/webroot/js/message.js b/webroot/js/message.js index 5744149b7..dddd19b83 100644 --- a/webroot/js/message.js +++ b/webroot/js/message.js @@ -237,7 +237,7 @@ class MessagingInterface { disableChat() { if (this.formMessageInput) { this.formMessageInput.disabled = true; - this.formMessageInput.placeholder = "Chat is offline." + this.formMessageInput.placeholder = CHAT_PLACEHOLDER_OFFLINE; } } enableChat() { @@ -248,11 +248,8 @@ class MessagingInterface { } setChatPlaceholderText() { - const firstMessageChatPlacholderText = "Type here to chat, no account necessary."; - const chatPlaceholderText = "Message" - const hasSentFirstChatMessage = getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT); - this.formMessageInput.placeholder = hasSentFirstChatMessage ? chatPlaceholderText : firstMessageChatPlacholderText + this.formMessageInput.placeholder = hasSentFirstChatMessage ? CHAT_PLACEHOLDER_TEXT : CHAT_INITIAL_PLACEHOLDER_TEXT } // handle Vue.js message display diff --git a/webroot/js/utils.js b/webroot/js/utils.js index 827137585..5c6b607eb 100644 --- a/webroot/js/utils.js +++ b/webroot/js/utils.js @@ -4,6 +4,7 @@ const LOCAL_TEST = window.location.href.indexOf('localhost:') >= 0; const URL_PREFIX = LOCAL_TEST ? 'http://localhost:8080' : ''; const URL_STATUS = `${URL_PREFIX}/status`; +const URL_CHAT_HISTORY = `${URL_PREFIX}/chat`; const URL_STREAM = `${URL_PREFIX}/hls/stream.m3u8`; const URL_WEBSOCKET = LOCAL_TEST ? 'wss://goth.land/entry' @@ -47,11 +48,16 @@ const KEY_CHAT_FIRST_MESSAGE_SENT = 'owncast_first_message_sent'; const TIMER_STATUS_UPDATE = 5000; // ms const TIMER_WEBSOCKET_RECONNECT = 5000; // ms const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins +const TIMER_STREAM_DURATION_COUNTER = 1000; const TEMP_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const MESSAGE_OFFLINE = 'Stream is offline.'; -const MESSAGE_ONLINE = 'Stream is online.'; +const MESSAGE_ONLINE = 'Stream is online'; + +const CHAT_INITIAL_PLACEHOLDER_TEXT = 'Type here to chat, no account necessary.'; +const CHAT_PLACEHOLDER_TEXT = 'Message'; +const CHAT_PLACEHOLDER_OFFLINE = 'Chat is offline.'; function getLocalStorage(key) { @@ -145,6 +151,21 @@ function generateUsername() { return `User ${(Math.floor(Math.random() * 42) + 1)}`; } +function secondsToHMMSS(seconds = 0) { + const finiteSeconds = Number.isFinite(+seconds) ? Math.abs(seconds) : 0; + + const hours = Math.floor(finiteSeconds / 3600); + const hoursString = hours ? `${hours}:` : ''; + + const mins = Math.floor((finiteSeconds / 60) % 60); + const minString = mins < 10 ? `0${mins}:` : `${mins}:`; + + const secs = Math.floor(finiteSeconds % 60); + const secsString = secs < 10 ? `0${secs}` : `${secs}`; + + return hoursString + minString + secsString; +} + function setVHvar() { var vh = window.innerHeight * 0.01; // Then we set the value in the --vh custom property to the root of the document