Browse Source
* objectify app away from window. wip * fix messaging obj binding; put logo behind video; fix /null issue with temp logo image * first pass at js refactor * remove unused files that had been consolidated during refactor * set up vue before getting config * add a few comments * dont use big arrow function, just bind, for safari * add airplay after instantiating video; check if input exists before disabling it;: * only set poster on pause during playback, and onEnded; take out sample videoJS tech options * disable chat after 5mins after going offline * move 'online' class to video container as it conflicts with dynamically change classnames from non-vue sources * disable chat based on lastdisconnecttime * fix typo; do offline mode onEnded instead of status offline * move offline ui display things to offline mode function; move poster setting on pause to main app to keep player obj cleaner; use opacity to hide video element on offline as sometimes control bars may still linger with vis:hidden * fixes' * don't autoplay. just show play button when stream is online so that it's easier to start playign without looking for the unmute button * clean up console logs Co-authored-by: Gabe Kangas <gabek@real-ity.com>pull/68/head
10 changed files with 502 additions and 324 deletions
@ -1,98 +1,279 @@
@@ -1,98 +1,279 @@
|
||||
async function setupApp() { |
||||
Vue.filter('plural', pluralize); |
||||
|
||||
window.app = new Vue({ |
||||
el: "#app-container", |
||||
data: { |
||||
streamStatus: MESSAGE_OFFLINE, // Default state.
|
||||
viewerCount: 0, |
||||
sessionMaxViewerCount: 0, |
||||
overallMaxViewerCount: 0, |
||||
messages: [], |
||||
extraUserContent: "", |
||||
isOnline: false, |
||||
layout: "desktop", |
||||
|
||||
// from config
|
||||
logo: null, |
||||
socialHandles: [], |
||||
streamerName: "", |
||||
summary: "", |
||||
tags: [], |
||||
title: "", |
||||
appVersion: "", |
||||
}, |
||||
watch: { |
||||
messages: { |
||||
deep: true, |
||||
handler: function (newMessages, oldMessages) { |
||||
if (newMessages.length !== oldMessages.length) { |
||||
// jump to bottom
|
||||
jumpToBottom(appMessaging.scrollableMessagesContainer); |
||||
} |
||||
class Owncast { |
||||
constructor() { |
||||
this.player; |
||||
this.streamStatus = null; |
||||
|
||||
this.websocket = null; |
||||
this.configData; |
||||
this.vueApp; |
||||
this.messagingInterface = null; |
||||
|
||||
// timers
|
||||
this.websocketReconnectTimer = null; |
||||
this.playerRestartTimer = null; |
||||
this.offlineTimer = null; |
||||
this.statusTimer = null; |
||||
this.disableChatTimer = null; |
||||
|
||||
// misc
|
||||
this.streamIsOnline = false; |
||||
this.lastDisconnectTime = null; |
||||
|
||||
Vue.filter('plural', pluralize); |
||||
|
||||
// bindings
|
||||
this.vueAppMounted = this.vueAppMounted.bind(this); |
||||
this.setConfigData = this.setConfigData.bind(this); |
||||
this.setupWebsocket = this.setupWebsocket.bind(this); |
||||
this.getStreamStatus = this.getStreamStatus.bind(this); |
||||
this.getExtraUserContent = this.getExtraUserContent.bind(this); |
||||
this.updateStreamStatus = this.updateStreamStatus.bind(this); |
||||
this.handleNetworkingError = this.handleNetworkingError.bind(this); |
||||
this.handleOfflineMode = this.handleOfflineMode.bind(this); |
||||
this.handleOnlineMode = this.handleOnlineMode.bind(this); |
||||
this.handleNetworkingError = this.handleNetworkingError.bind(this); |
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this); |
||||
this.handlePlayerPlaying = this.handlePlayerPlaying.bind(this); |
||||
this.handlePlayerEnded = this.handlePlayerEnded.bind(this); |
||||
this.handlePlayerError = this.handlePlayerError.bind(this); |
||||
} |
||||
|
||||
init() { |
||||
this.messagingInterface = new MessagingInterface(); |
||||
this.websocket = this.setupWebsocket(); |
||||
|
||||
this.vueApp = new Vue({ |
||||
el: '#app-container', |
||||
data: { |
||||
isOnline: false, |
||||
layout: hasTouchScreen() ? 'touch' : 'desktop', |
||||
messages: [], |
||||
overallMaxViewerCount: 0, |
||||
sessionMaxViewerCount: 0, |
||||
streamStatus: MESSAGE_OFFLINE, // Default state.
|
||||
viewerCount: 0, |
||||
|
||||
// from config
|
||||
appVersion: '', |
||||
extraUserContent: '', |
||||
logo: TEMP_IMAGE, |
||||
logoLarge: TEMP_IMAGE, |
||||
socialHandles: [], |
||||
streamerName: '', |
||||
summary: '', |
||||
tags: [], |
||||
title: '', |
||||
}, |
||||
watch: { |
||||
messages: { |
||||
deep: true, |
||||
handler: this.messagingInterface.onReceivedMessages, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
mounted: this.vueAppMounted, |
||||
}); |
||||
} |
||||
// do all these things after Vue.js has mounted, else we'll get weird DOM issues.
|
||||
vueAppMounted() { |
||||
this.getConfig(); |
||||
this.messagingInterface.init(); |
||||
|
||||
this.player = new OwncastPlayer(); |
||||
this.player.setupPlayerCallbacks({ |
||||
onReady: this.handlePlayerReady, |
||||
onPlaying: this.handlePlayerPlaying, |
||||
onEnded: this.handlePlayerEnded, |
||||
onError: this.handlePlayerError, |
||||
}); |
||||
this.player.init(); |
||||
}; |
||||
|
||||
setConfigData(data) { |
||||
this.vueApp.appVersion = data.version; |
||||
this.vueApp.logo = data.logo.small; |
||||
this.vueApp.logoLarge = data.logo.large; |
||||
this.vueApp.socialHandles = data.socialHandles; |
||||
this.vueApp.streamerName = data.name; |
||||
this.vueApp.summary = data.summary && addNewlines(data.summary); |
||||
this.vueApp.tags = data.tags; |
||||
this.vueApp.title = data.title; |
||||
|
||||
window.document.title = data.title; |
||||
|
||||
this.getExtraUserContent(`${URL_PREFIX}${data.extraUserInfoFileName}`); |
||||
|
||||
this.configData = data; |
||||
} |
||||
|
||||
// init messaging interactions
|
||||
var appMessaging = new Messaging(); |
||||
appMessaging.init(); |
||||
// websocket for messaging
|
||||
setupWebsocket() { |
||||
var ws = new WebSocket(URL_WEBSOCKET); |
||||
ws.onopen = (e) => { |
||||
if (this.websocketReconnectTimer) { |
||||
clearTimeout(this.websocketReconnectTimer); |
||||
} |
||||
}; |
||||
ws.onclose = (e) => { |
||||
// connection closed, discard old websocket and create a new one in 5s
|
||||
this.websocket = null; |
||||
this.messagingInterface.disableChat(); |
||||
this.handleNetworkingError('Websocket closed.'); |
||||
this.websocketReconnectTimer = setTimeout(this.setupWebsocket, TIMER_WEBSOCKET_RECONNECT); |
||||
}; |
||||
// On ws error just close the socket and let it re-connect again for now.
|
||||
ws.onerror = e => { |
||||
this.handleNetworkingError(`Stream status: ${e}`); |
||||
ws.close(); |
||||
}; |
||||
ws.onmessage = (e) => { |
||||
const model = JSON.parse(e.data); |
||||
// Ignore non-chat messages (such as keepalive PINGs)
|
||||
if (model.type !== SOCKET_MESSAGE_TYPES.CHAT) { |
||||
return; |
||||
} |
||||
const message = new Message(model); |
||||
const existing = this.vueApp.messages.filter(function (item) { |
||||
return item.id === message.id; |
||||
}) |
||||
if (existing.length === 0 || !existing) { |
||||
this.vueApp.messages = [...this.vueApp.messages, message]; |
||||
} |
||||
}; |
||||
this.websocket = ws; |
||||
this.messagingInterface.setWebsocket(this.websocket); |
||||
}; |
||||
|
||||
const config = await new Config().init(); |
||||
app.logo = config.logo.small; |
||||
app.socialHandles = config.socialHandles; |
||||
app.streamerName = config.name; |
||||
app.summary = config.summary && addNewlines(config.summary); |
||||
app.tags = config.tags; |
||||
app.appVersion = config.version; |
||||
app.title = config.title; |
||||
window.document.title = config.title; |
||||
// fetch /config data
|
||||
getConfig() { |
||||
fetch(URL_CONFIG) |
||||
.then(response => { |
||||
if (!response.ok) { |
||||
throw new Error(`Network response was not ok ${response.ok}`); |
||||
} |
||||
return response.json(); |
||||
}) |
||||
.then(json => { |
||||
this.setConfigData(json); |
||||
}) |
||||
.catch(error => { |
||||
this.handleNetworkingError(`Fetch config: ${error}`); |
||||
}); |
||||
} |
||||
|
||||
getExtraUserContent(`${URL_PREFIX}${config.extraUserInfoFileName}`); |
||||
} |
||||
// fetch stream status
|
||||
getStreamStatus() { |
||||
fetch(URL_STATUS) |
||||
.then(response => { |
||||
if (!response.ok) { |
||||
throw new Error(`Network response was not ok ${response.ok}`); |
||||
} |
||||
return response.json(); |
||||
}) |
||||
.then(json => { |
||||
this.updateStreamStatus(json); |
||||
}) |
||||
.catch(error => { |
||||
this.handleOfflineMode(); |
||||
this.handleNetworkingError(`Stream status: ${error}`); |
||||
}); |
||||
}; |
||||
|
||||
var websocketReconnectTimer; |
||||
function setupWebsocket() { |
||||
clearTimeout(websocketReconnectTimer); |
||||
// fetch content.md
|
||||
getExtraUserContent(path) { |
||||
fetch(path) |
||||
.then(response => { |
||||
if (!response.ok) { |
||||
throw new Error(`Network response was not ok ${response.ok}`); |
||||
} |
||||
return response.text(); |
||||
}) |
||||
.then(text => { |
||||
const descriptionHTML = new showdown.Converter().makeHtml(text); |
||||
this.vueApp.extraUserContent = descriptionHTML; |
||||
}) |
||||
.catch(error => { |
||||
this.handleNetworkingError(`Fetch extra content: ${error}`); |
||||
}); |
||||
}; |
||||
|
||||
var ws = new WebSocket(URL_WEBSOCKET); |
||||
// handle UI things from stream status result
|
||||
updateStreamStatus(status) { |
||||
// update UI
|
||||
this.vueApp.streamStatus = status.online ? MESSAGE_ONLINE : MESSAGE_OFFLINE; |
||||
this.vueApp.viewerCount = status.viewerCount; |
||||
this.vueApp.sessionMaxViewerCount = status.sessionMaxViewerCount; |
||||
this.vueApp.overallMaxViewerCount = status.overallMaxViewerCount; |
||||
|
||||
ws.onmessage = (e) => { |
||||
const model = JSON.parse(e.data); |
||||
this.lastDisconnectTime = status.lastDisconnectTime; |
||||
|
||||
// Ignore non-chat messages (such as keepalive PINGs)
|
||||
if (model.type !== SOCKET_MESSAGE_TYPES.CHAT) { return; } |
||||
if (status.online && !this.streamIsOnline) { |
||||
// stream has just come online.
|
||||
this.handleOnlineMode(); |
||||
} else if (!status.online && !this.streamStatus) { |
||||
// stream has just gone offline.
|
||||
// display offline mode the first time we get status, and it's offline.
|
||||
this.handleOfflineMode(); |
||||
} |
||||
|
||||
const message = new Message(model); |
||||
|
||||
const existing = this.app.messages.filter(function (item) { |
||||
return item.id === message.id; |
||||
}) |
||||
|
||||
if (existing.length === 0 || !existing) { |
||||
this.app.messages = [...this.app.messages, message]; |
||||
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(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
ws.onclose = (e) => { |
||||
// connection closed, discard old websocket and create a new one in 5s
|
||||
ws = null; |
||||
console.log("Websocket closed.") |
||||
websocketReconnectTimer = setTimeout(setupWebsocket, 5000); |
||||
} |
||||
this.streamStatus = status; |
||||
}; |
||||
|
||||
handleNetworkingError(error) { |
||||
console.log(`>>> App Error: ${error}`) |
||||
}; |
||||
|
||||
// basically hide video and show underlying "poster"
|
||||
handleOfflineMode() { |
||||
this.streamIsOnline = false; |
||||
this.vueApp.isOnline = false; |
||||
this.vueApp.streamStatus = MESSAGE_OFFLINE; |
||||
|
||||
if (this.lastDisconnectTime) { |
||||
const remainingChatTime = TIMER_DISABLE_CHAT_AFTER_OFFLINE - (Date.now() - new Date(this.lastDisconnectTime)); |
||||
const countdown = (remainingChatTime < 0) ? 0 : remainingChatTime; |
||||
this.disableChatTimer = setTimeout(this.messagingInterface.disableChat, countdown); |
||||
} |
||||
}; |
||||
|
||||
// On ws error just close the socket and let it re-connect again for now.
|
||||
ws.onerror = (e) => { |
||||
console.log("Websocket error: ", e); |
||||
ws.close(); |
||||
// play video!
|
||||
handleOnlineMode() { |
||||
this.streamIsOnline = true; |
||||
this.vueApp.isOnline = true; |
||||
this.vueApp.streamStatus = MESSAGE_ONLINE; |
||||
|
||||
this.player.startPlayer(); |
||||
clearTimeout(this.disableChatTimer); |
||||
this.disableChatTimer = null; |
||||
this.messagingInterface.enableChat(); |
||||
} |
||||
|
||||
window.ws = ws; |
||||
} |
||||
// when videojs player is ready, start polling for stream
|
||||
handlePlayerReady() { |
||||
this.getStreamStatus(); |
||||
this.statusTimer = setInterval(this.getStreamStatus, TIMER_STATUS_UPDATE); |
||||
}; |
||||
|
||||
|
||||
handlePlayerPlaying() { |
||||
// do something?
|
||||
}; |
||||
|
||||
setupApp(); |
||||
|
||||
setupWebsocket(); |
||||
handlePlayerEnded() { |
||||
// do something?
|
||||
this.handleOfflineMode(); |
||||
}; |
||||
|
||||
handlePlayerError() { |
||||
// do something?
|
||||
this.handleOfflineMode(); |
||||
// stop timers?
|
||||
}; |
||||
}; |
||||
|
@ -1,16 +0,0 @@
@@ -1,16 +0,0 @@
|
||||
// add more to the promises later.
|
||||
class Config { |
||||
async init() { |
||||
const configFileLocation = "/config"; |
||||
|
||||
try { |
||||
const response = await fetch(configFileLocation); |
||||
const configData = await response.json(); |
||||
Object.assign(this, configData); |
||||
return this; |
||||
} catch(error) { |
||||
console.log(error); |
||||
// No config file present. That's ok. It's not required.
|
||||
} |
||||
} |
||||
} |
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
// https://docs.videojs.com/player
|
||||
|
||||
class OwncastPlayer { |
||||
constructor() { |
||||
window.VIDEOJS_NO_DYNAMIC_STYLE = true; // style override
|
||||
|
||||
this.vjsPlayer = null; |
||||
|
||||
this.appPlayerReadyCallback = null; |
||||
this.appPlayerPlayingCallback = null; |
||||
this.appPlayerEndedCallback = null; |
||||
|
||||
// bind all the things because safari
|
||||
this.startPlayer = this.startPlayer.bind(this); |
||||
this.handleReady = this.handleReady.bind(this); |
||||
this.handlePlaying = this.handlePlaying.bind(this); |
||||
this.handleEnded = this.handleEnded.bind(this); |
||||
this.handleError = this.handleError.bind(this); |
||||
} |
||||
init() { |
||||
this.vjsPlayer = videojs(VIDEO_ID, VIDEO_OPTIONS); |
||||
this.addAirplay(); |
||||
this.vjsPlayer.ready(this.handleReady); |
||||
} |
||||
|
||||
setupPlayerCallbacks(callbacks) { |
||||
const { onReady, onPlaying, onEnded, onError } = callbacks; |
||||
|
||||
this.appPlayerReadyCallback = onReady; |
||||
this.appPlayerPlayingCallback = onPlaying; |
||||
this.appPlayerEndedCallback = onEnded; |
||||
this.appPlayerErrorCallback = onError; |
||||
} |
||||
|
||||
// play
|
||||
startPlayer() { |
||||
this.log('Start playing'); |
||||
this.vjsPlayer.src(VIDEO_SRC); |
||||
// this.vjsPlayer.play();
|
||||
}; |
||||
|
||||
handleReady() { |
||||
this.log('on Ready'); |
||||
this.vjsPlayer.on('error', this.handleError); |
||||
this.vjsPlayer.on('playing', this.handlePlaying); |
||||
this.vjsPlayer.on('ended', this.handleEnded); |
||||
|
||||
if (this.appPlayerReadyCallback) { |
||||
// start polling
|
||||
this.appPlayerReadyCallback(); |
||||
} |
||||
} |
||||
|
||||
handlePlaying() { |
||||
this.log('on Playing'); |
||||
if (this.appPlayerPlayingCallback) { |
||||
// start polling
|
||||
this.appPlayerPlayingCallback(); |
||||
} |
||||
} |
||||
|
||||
handleEnded() { |
||||
this.log('on Ended'); |
||||
if (this.appPlayerEndedCallback) { |
||||
this.appPlayerEndedCallback(); |
||||
} |
||||
this.setPoster(); |
||||
} |
||||
|
||||
handleError(e) { |
||||
this.log(`on Error: ${JSON.stringify(e)}`); |
||||
if (this.appPlayerEndedCallback) { |
||||
this.appPlayerEndedCallback(); |
||||
} |
||||
} |
||||
|
||||
setPoster() { |
||||
const cachebuster = Math.round(new Date().getTime() / 1000); |
||||
const poster = POSTER_THUMB + "?okhi=" + cachebuster; |
||||
|
||||
this.vjsPlayer.poster(poster); |
||||
} |
||||
|
||||
log(message) { |
||||
console.log(`>>> Player: ${message}`); |
||||
} |
||||
|
||||
addAirplay() { |
||||
videojs.hookOnce('setup', function (player) { |
||||
if (window.WebKitPlaybackTargetAvailabilityEvent) { |
||||
var videoJsButtonClass = videojs.getComponent('Button'); |
||||
var concreteButtonClass = videojs.extend(videoJsButtonClass, { |
||||
|
||||
// The `init()` method will also work for constructor logic here, but it is
|
||||
// deprecated. If you provide an `init()` method, it will override the
|
||||
// `constructor()` method!
|
||||
constructor: function () { |
||||
videoJsButtonClass.call(this, player); |
||||
}, // notice the comma
|
||||
|
||||
handleClick: function () { |
||||
const videoElement = document.getElementsByTagName('video')[0]; |
||||
videoElement.webkitShowPlaybackTargetPicker(); |
||||
} |
||||
}); |
||||
|
||||
var concreteButtonInstance = this.vjsPlayer.controlBar.addChild(new concreteButtonClass()); |
||||
concreteButtonInstance.addClass("vjs-airplay"); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
|
||||
} |
||||
|
@ -1,23 +0,0 @@
@@ -1,23 +0,0 @@
|
||||
videojs.hookOnce('setup', function (player) { |
||||
if (window.WebKitPlaybackTargetAvailabilityEvent) { |
||||
var videoJsButtonClass = videojs.getComponent('Button'); |
||||
var concreteButtonClass = videojs.extend(videoJsButtonClass, { |
||||
|
||||
// The `init()` method will also work for constructor logic here, but it is
|
||||
// deprecated. If you provide an `init()` method, it will override the
|
||||
// `constructor()` method!
|
||||
constructor: function () { |
||||
videoJsButtonClass.call(this, player); |
||||
}, // notice the comma
|
||||
|
||||
handleClick: function () { |
||||
const videoElement = document.getElementsByTagName('video')[0]; |
||||
videoElement.webkitShowPlaybackTargetPicker(); |
||||
} |
||||
}); |
||||
|
||||
var concreteButtonInstance = player.controlBar.addChild(new concreteButtonClass()); |
||||
concreteButtonInstance.addClass("vjs-airplay"); |
||||
} |
||||
|
||||
}); |
@ -1,75 +0,0 @@
@@ -1,75 +0,0 @@
|
||||
// const streamURL = '/hls/stream.m3u8';
|
||||
const streamURL = '/hls/stream.m3u8'; // Uncomment me to point to remote video
|
||||
|
||||
// style hackings
|
||||
window.VIDEOJS_NO_DYNAMIC_STYLE = true; |
||||
|
||||
// Create the player for the first time
|
||||
const player = videojs(VIDEO_ID, null, function () { |
||||
getStatus(); |
||||
setInterval(getStatus, 5000); |
||||
setupPlayerEventHandlers(); |
||||
}) |
||||
|
||||
player.ready(function () { |
||||
console.log('Player ready.') |
||||
resetPlayer(player); |
||||
}); |
||||
|
||||
function resetPlayer(player) { |
||||
player.reset(); |
||||
player.src({ type: 'application/x-mpegURL', src: URL_STREAM }); |
||||
setVideoPoster(app.isOnline); |
||||
} |
||||
|
||||
function setupPlayerEventHandlers() { |
||||
const player = videojs(VIDEO_ID); |
||||
|
||||
player.on('error', function (e) { |
||||
console.log("Player error: ", e); |
||||
}) |
||||
|
||||
// player.on('loadeddata', function (e) {
|
||||
// console.log("loadeddata");
|
||||
// })
|
||||
|
||||
player.on('ended', function (e) { |
||||
console.log("ended"); |
||||
resetPlayer(player); |
||||
}) |
||||
//
|
||||
// player.on('abort', function (e) {
|
||||
// console.log("abort");
|
||||
// })
|
||||
//
|
||||
// player.on('durationchange', function (e) {
|
||||
// console.log("durationchange");
|
||||
// })
|
||||
//
|
||||
// player.on('stalled', function (e) {
|
||||
// console.log("stalled");
|
||||
// })
|
||||
//
|
||||
player.on('playing', function (e) { |
||||
if (playerRestartTimer) { |
||||
clearTimeout(playerRestartTimer); |
||||
} |
||||
}) |
||||
//
|
||||
// player.on('waiting', function (e) {
|
||||
// // console.log("waiting");
|
||||
// })
|
||||
} |
||||
|
||||
function restartPlayer() { |
||||
try { |
||||
const player = videojs(VIDEO_ID); |
||||
player.pause(); |
||||
player.src(player.src()); // Reload the same video
|
||||
player.load(); |
||||
player.play(); |
||||
} catch (e) { |
||||
console.log(e) |
||||
} |
||||
|
||||
} |
@ -1,44 +0,0 @@
@@ -1,44 +0,0 @@
|
||||
var playerRestartTimer; |
||||
|
||||
|
||||
function handleStatus(status) { |
||||
clearTimeout(playerRestartTimer); |
||||
if (!app.isOnline && status.online) { |
||||
// The stream was offline, but now it's online. Force start of playback after an arbitrary delay to make sure the stream has actual data ready to go.
|
||||
playerRestartTimer = setTimeout(restartPlayer, 3000); |
||||
} |
||||
|
||||
app.streamStatus = status.online ? MESSAGE_ONLINE : MESSAGE_OFFLINE; |
||||
|
||||
app.viewerCount = status.viewerCount; |
||||
app.sessionMaxViewerCount = status.sessionMaxViewerCount; |
||||
app.overallMaxViewerCount = status.overallMaxViewerCount; |
||||
app.isOnline = status.online; |
||||
// setVideoPoster(app.isOnline);
|
||||
} |
||||
|
||||
function handleOffline() { |
||||
const player = videojs(VIDEO_ID); |
||||
player.poster(POSTER_DEFAULT); |
||||
app.streamStatus = MESSAGE_OFFLINE; |
||||
app.viewerCount = 0; |
||||
} |
||||
|
||||
function getStatus() { |
||||
const options = { |
||||
// mode: 'no-cors',
|
||||
} |
||||
fetch(URL_STATUS, options) |
||||
.then(response => { |
||||
if (!response.ok) { |
||||
throw new Error(`Network response was not ok ${response.ok}`); |
||||
} |
||||
return response.json(); |
||||
}) |
||||
.then(json => { |
||||
handleStatus(json); |
||||
}) |
||||
.catch(error => { |
||||
handleOffline(); |
||||
}); |
||||
} |
Loading…
Reference in new issue