diff --git a/packages/rocketchat-lib/client/lib/RocketChatAnnouncement.js b/packages/rocketchat-lib/client/lib/RocketChatAnnouncement.js index 42c5a45f30dd..07950144cd18 100644 --- a/packages/rocketchat-lib/client/lib/RocketChatAnnouncement.js +++ b/packages/rocketchat-lib/client/lib/RocketChatAnnouncement.js @@ -18,4 +18,19 @@ export class RocketChatAnnouncement { getStyle() { return this.callback.get(); } + getByRoom(rid) { + const roomData = Session.get(`roomData${ rid }`); + if (!roomData) { return null; } + this.room.set(rid); + this.message.set(roomData.announcement); + this.callback.set(roomData.announcementDetails ? roomData.announcementDetails.callback : null); + this.style.set(roomData.announcementDetails ? roomData.announcementDetails.style : null); + return this; + } + clear() { + this.message.set(null); + this.callback.set(null); + this.style.set(null); + Meteor.call('saveRoomSettings', this.room.get(), 'roomAnnouncement', {}); + } } diff --git a/packages/rocketchat-lib/client/lib/TabBar.js b/packages/rocketchat-lib/client/lib/TabBar.js index 714c4c08ee1f..020f180c1186 100644 --- a/packages/rocketchat-lib/client/lib/TabBar.js +++ b/packages/rocketchat-lib/client/lib/TabBar.js @@ -1,9 +1,15 @@ import _ from 'underscore'; RocketChat.TabBar = new (class TabBar { + get size() { + return this._size.get(); + } + set size(s) { + this._size.set(s); + } constructor() { this.buttons = new ReactiveVar({}); - + this._size = new ReactiveVar(4); this.extraGroups = {}; } diff --git a/packages/rocketchat-livestream/.gitignore b/packages/rocketchat-livestream/.gitignore new file mode 100644 index 000000000000..f22188fc0aaf --- /dev/null +++ b/packages/rocketchat-livestream/.gitignore @@ -0,0 +1 @@ +.npm/ diff --git a/packages/rocketchat-livestream/client/oauth.js b/packages/rocketchat-livestream/client/oauth.js new file mode 100644 index 000000000000..73be90578b13 --- /dev/null +++ b/packages/rocketchat-livestream/client/oauth.js @@ -0,0 +1,15 @@ +export const close = (popup) => { + return new Promise(function(resolve) { + const checkInterval = setInterval(() => { + if (popup.closed) { + clearInterval(checkInterval); + resolve(); + } + }, 300); + }); +}; + +export const auth = async() => { + const oauthWindow = window.open(`${ RocketChat.settings.get('Site_Url') }/api/v1/livestream/oauth?userId=${ Meteor.userId() }`, 'youtube-integration-oauth', 'width=400,height=600'); + return await close(oauthWindow); +}; diff --git a/packages/rocketchat-livestream/client/styles/liveStreamTab.css b/packages/rocketchat-livestream/client/styles/liveStreamTab.css index 220196796806..862f593b4252 100644 --- a/packages/rocketchat-livestream/client/styles/liveStreamTab.css +++ b/packages/rocketchat-livestream/client/styles/liveStreamTab.css @@ -2,27 +2,35 @@ &.livestream { justify-content: flex-start; } + & .rc-button--stack { + width: 100%; + } } .thumbnail-container { position: relative; height: 270px; - & .--absolute-center { - position: absolute; - top: calc(50% - 25px); - left: calc(50% - 25px); + & .popout { + &--absolute-center { + position: absolute; + top: calc(50% - 25px); + left: calc(50% - 25px); - font-size: 50px; - } - & .--play-solid { - fill: var(--rc-color-primary-lightest);; + font-size: 50px; + } + &--play-solid { + fill: white; + } } } .liveStreamTab__form { & .rc-switch { padding: 15px 5px; } + & .rc-input__label p { + padding: 10px 0; + } } .livestream--url { @@ -37,7 +45,7 @@ & .current-setting { display: inline-block; text-decoration: none; - color: var(--rc-color-primary-dark); + color: black; } & .rc-button { diff --git a/packages/rocketchat-livestream/client/tabBar.js b/packages/rocketchat-livestream/client/tabBar.js index b7409dc3a047..75b5a777f682 100644 --- a/packages/rocketchat-livestream/client/tabBar.js +++ b/packages/rocketchat-livestream/client/tabBar.js @@ -1,16 +1,21 @@ Meteor.startup(function() { Tracker.autorun(function() { + RocketChat.TabBar.removeButton('livestream'); if (RocketChat.settings.get('Livestream_enabled')) { + const live = RocketChat.models.Rooms.findOne({ _id: Session.get('openedRoom'), 'streamingOptions.id': {$exists :1} }, { fields: { streamingOptions: 1 } }); + RocketChat.TabBar.size = live ? 5 : 4; return RocketChat.TabBar.addButton({ groups: ['channel', 'group'], id: 'livestream', i18nTitle: 'Livestream', icon: 'podcast', template: 'liveStreamTab', - order: 3 + order: live ? -1 : 15, + class: () => { + const roomWithStream = RocketChat.models.Rooms.findOne({ _id: Session.get('openedRoom'), 'streamingOptions.id': {$exists :1} }, { fields: { streamingOptions: 1 } }) || ''; + return roomWithStream && 'live'; + } }); - } else { - RocketChat.TabBar.removeButton('livestream'); } }); }); diff --git a/packages/rocketchat-livestream/client/views/broadcastView.html b/packages/rocketchat-livestream/client/views/broadcastView.html new file mode 100644 index 000000000000..44d69807f7a5 --- /dev/null +++ b/packages/rocketchat-livestream/client/views/broadcastView.html @@ -0,0 +1,5 @@ + diff --git a/packages/rocketchat-livestream/client/views/broadcastView.js b/packages/rocketchat-livestream/client/views/broadcastView.js new file mode 100644 index 000000000000..252dac7bf441 --- /dev/null +++ b/packages/rocketchat-livestream/client/views/broadcastView.js @@ -0,0 +1,150 @@ +const getMedia = () => navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; +const createAndConnect = (url) => { + if (!'WebSocket' in window) { // eslint-disable-line no-negated-in-lhs + return false; + } + + const ws = new WebSocket(url); + ws.onerror = (evt) => console.error(`Error: ${ evt.data }`); + return ws; +}; +const sendMessageToWebSocket = (message, ws) => { + if (ws != null) { + if (ws.readyState === 1) { ws.send(message); } + } +}; +export const call = (...args) => new Promise(function(resolve, reject) { + Meteor.call(...args, function(err, result) { + if (err) { + handleError(err); + reject(err); + } + resolve(result); + }); +}); + +const delay = (time) => new Promise((resolve) => setTimeout(resolve, time)); + +const waitForStreamStatus = async(id, status) => { + const streamActive = new Promise(async(resolve) => { + while (true) { //eslint-disable-line no-constant-condition + const currentStatus = await call('livestreamStreamStatus', { streamId: id }); + if (currentStatus === status) { + return resolve(status); + } + await delay(1500); + } + }); + await streamActive; +}; +const waitForBroadcastStatus = async(id, status) => { + const broadcastActive = new Promise(async(resolve) => { + while (true) { //eslint-disable-line no-constant-condition + const currentStatus = await call('getBroadcastStatus', { broadcastId: id }); + if (currentStatus === status) { + return resolve(status); + } + await delay(1500); + } + }); + await broadcastActive; +}; + +Template.broadcastView.helpers({ + broadcastSource() { + return Template.instance().mediaStream.get() ? window.URL.createObjectURL(Template.instance().mediaStream.get()) : ''; + }, + mediaRecorder() { + Template.instance().mediaRecorder.get(); + } +}); + +Template.broadcastView.onCreated(async function() { + const connection = createAndConnect(`${ RocketChat.settings.get('Broadcasting_media_server_url') }/${ this.data.id }`); + this.mediaStream = new ReactiveVar(null); + this.mediaRecorder = new ReactiveVar(null); + this.connection = new ReactiveVar(connection); + + if (!connection) { + return; + } +}); +Template.broadcastView.onDestroyed(function() { + if (this.connection.get()) { + this.connection.get().close(); + } + if (this.mediaRecorder.get()) { + this.mediaRecorder.get().stop(); + this.mediaRecorder.set(null); + } + if (this.mediaStream.get()) { + this.mediaStream.get().getTracks().map((track) => track.stop()); + this.mediaStream.set(null); + } +}); +Template.broadcastView.onRendered(async function() { + navigator.getMedia = getMedia(); + if (!navigator.getMedia) { + return alert('getUserMedia() is not supported in your browser!'); + } + const localMediaStream = await new Promise((resolve, reject) => navigator.getMedia({video: true, audio: true}, resolve, reject)); + + const connection = this.connection.get(); + + this.mediaStream.set(localMediaStream); + let options = {mimeType: 'video/webm;codecs=vp9'}; + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = {mimeType: 'video/webm;codecs=vp8'}; + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = {mimeType: 'video/webm'}; + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = {mimeType: ''}; + } + } + } + try { + const mediaRecorder = new MediaRecorder(localMediaStream, options); + mediaRecorder.ondataavailable = (event) => { + if (!(event.data || event.data.size > 0)) { + return; + } + sendMessageToWebSocket(event.data, connection); + }; + mediaRecorder.start(100); // collect 100ms of data + this.mediaRecorder.set(mediaRecorder); + + await waitForStreamStatus(this.data.stream.id, 'active'); + await call('setLivestreamStatus', { broadcastId: this.data.broadcast.id, status: 'testing' }); + await waitForBroadcastStatus(this.data.broadcast.id, 'testing'); + document.querySelector('.streaming-popup').dispatchEvent(new Event('broadcastStreamReady')); + + } catch (e) { + console.log(e); + } +}); + +Template.broadcastView.events({ + async 'startStreaming .streaming-popup'(e, i) { + await call('setLivestreamStatus', {broadcastId: i.data.broadcast.id, status: 'live'}); + document.querySelector('.streaming-popup').dispatchEvent(new Event('broadcastStream')); + await call('saveRoomSettings', Session.get('openedRoom'), 'streamingOptions', {id: i.data.broadcast.id, url: `https://www.youtube.com/embed/${ i.data.broadcast.id }`, thumbnail: `https://img.youtube.com/vi/${ i.data.broadcast.id }/0.jpg`}); + }, + async 'stopStreaming .streaming-popup'(e, i) { + await call('setBroadcastStatus', { broadcastId: i.data.broadcast.id, status: 'complete' }); + await call('saveRoomSettings', Session.get('openedRoom'), 'streamingOptions', {}, (err) => { + if (err) { + return handleError(err); + } + i.editing.set(false); + i.streamingOptions.set({}); + }); + if (i.mediaRecorder.get()) { + i.mediaRecorder.get().stop(); + i.mediaRecorder.set(null); + } + if (i.mediaStream.get()) { + i.mediaStream.get().getTracks().map((track) => track.stop()); + i.mediaStream.set(null); + } + } +}); diff --git a/packages/rocketchat-livestream/client/views/liveStreamTab.html b/packages/rocketchat-livestream/client/views/liveStreamTab.html index 7ecad50cad6c..c92bd08c043f 100644 --- a/packages/rocketchat-livestream/client/views/liveStreamTab.html +++ b/packages/rocketchat-livestream/client/views/liveStreamTab.html @@ -9,23 +9,33 @@ {{else}} {{#if hasThumbnail}}
- - {{> icon icon="play-solid"}} + + {{> icon icon="play-solid" block="popout"}}
{{else}} - - {{> icon icon="play-solid"}} + + {{> icon icon="play-solid" block="popout"}} {{/if}} {{/if}} {{else}} -

{{_ "Livestream_not_found" }}

+

{{ streamingUnavailableMessage }}

{{/if}} {{#if canEdit}} {{#if editing}} + {{#if broadcastEnabled}} + {{ > liveStreamBroadcast}} + {{/if}}
- + +
- + +
{{else}} diff --git a/packages/rocketchat-livestream/client/views/liveStreamTab.js b/packages/rocketchat-livestream/client/views/liveStreamTab.js index f888906dd144..8d06f777e3c0 100644 --- a/packages/rocketchat-livestream/client/views/liveStreamTab.js +++ b/packages/rocketchat-livestream/client/views/liveStreamTab.js @@ -1,5 +1,29 @@ /* globals popout */ import toastr from 'toastr'; +import { auth } from '../oauth.js'; +import { RocketChatAnnouncement } from 'meteor/rocketchat:lib'; + + +export const call = (...args) => new Promise(function(resolve, reject) { + Meteor.call(...args, function(err, result) { + if (err) { + handleError(err); + reject(err); + } + resolve(result); + }); +}); + +export const close = (popup) => { + return new Promise(function(resolve) { + const checkInterval = setInterval(() => { + if (popup.closed) { + clearInterval(checkInterval); + resolve(); + } + }, 300); + }); +}; function optionsFromUrl(url) { const options = {}; @@ -17,9 +41,15 @@ function optionsFromUrl(url) { } Template.liveStreamTab.helpers({ + broadcastEnabled() { + return !!RocketChat.settings.get('Broadcasting_enabled'); + }, streamingSource() { return Template.instance().streamingOptions.get() ? Template.instance().streamingOptions.get().url : ''; }, + streamingUnavailableMessage() { + return Template.instance().streamingOptions.get() && Template.instance().streamingOptions.get().message && Template.instance().streamingOptions.get().message !== '' ? Template.instance().streamingOptions.get().message : t('Livestream_not_found'); + }, thumbnailUrl() { return Template.instance().streamingOptions.get() ? Template.instance().streamingOptions.get().thumbnail : ''; }, @@ -83,26 +113,55 @@ Template.liveStreamTab.events({ e.preventDefault(); i.editing.set(false); }, + 'click .js-clear'(e, i) { + e.preventDefault(); + + const clearedObject = { + message: i.streamingOptions.get().message || '', + isAudioOnly: i.streamingOptions.get().isAudioOnly || false + }; + + Meteor.call('saveRoomSettings', this.rid, 'streamingOptions', clearedObject, function(err) { + if (err) { + return handleError(err); + } + i.editing.set(false); + i.streamingOptions.set(clearedObject); + const roomAnnouncement = new RocketChatAnnouncement().getByRoom(i.data.rid); + if (roomAnnouncement.getMessage() !== '') { roomAnnouncement.clear(); } + return toastr.success(TAPi18n.__('Livestream_source_changed_succesfully')); + }); + }, 'click .js-save'(e, i) { e.preventDefault(); const streamingOptions = { ...optionsFromUrl(i.find('[name=streaming-source]').value), - isAudioOnly: i.find('[name=streaming-audio-only]').checked + isAudioOnly: i.find('[name=streaming-audio-only]').checked, + message: i.find('[name=streaming-message]').value }; - if (streamingOptions.id != null) { - Meteor.call('saveRoomSettings', this.rid, 'streamingOptions', streamingOptions, function(err) { - if (err) { - return handleError(err); + Meteor.call('saveRoomSettings', this.rid, 'streamingOptions', streamingOptions, function(err) { + if (err) { + return handleError(err); + } + i.editing.set(false); + i.streamingOptions.set(streamingOptions); + if (streamingOptions.url !== '') { + new RocketChatAnnouncement({ + room: i.data.rid, + message: 'Broadcast is now live. Click here to watch!', + callback: 'openBroadcast' + }).save(); + } else { + const roomAnnouncement = new RocketChatAnnouncement().getByRoom(i.data.rid); + if (roomAnnouncement.getMessage() !== '') { + roomAnnouncement.clear(); } - i.editing.set(false); - i.streamingOptions.set(streamingOptions); - return toastr.success(TAPi18n.__('Livestream_source_changed_succesfully')); - }); - } else { - return toastr.error(TAPi18n.__('Livestream_url_incorrect')); - } + } + + return toastr.success(TAPi18n.__('Livestream_source_changed_succesfully')); + }); }, 'click .streaming-source-settings'(e, i) { e.preventDefault(); @@ -138,21 +197,30 @@ Template.liveStreamTab.events({ const streamingOptions = { ...optionsFromUrl(i.find('[name=streaming-source]').value), - isAudioOnly: i.find('[name=streaming-audio-only]').checked + isAudioOnly: i.find('[name=streaming-audio-only]').checked, + message: i.find('[name=streaming-message]').value }; - if (streamingOptions.id != null) { - Meteor.call('saveRoomSettings', this.rid, 'streamingOptions', streamingOptions, function(err) { - if (err) { - return handleError(err); + Meteor.call('saveRoomSettings', this.rid, 'streamingOptions', streamingOptions, function(err) { + if (err) { + return handleError(err); + } + i.editing.set(false); + i.streamingOptions.set(streamingOptions); + if (streamingOptions.url !== '') { + new RocketChatAnnouncement({ + room: i.data.rid, + message: 'Broadcast is now live. Click here to watch!', + callback: 'openBroadcast' + }).save(); + } else { + const roomAnnouncement = new RocketChatAnnouncement().getByRoom(i.data.rid); + if (roomAnnouncement.getMessage() !== '') { + roomAnnouncement.clear(); } - i.editing.set(false); - i.streamingOptions.set(streamingOptions); - return toastr.success(TAPi18n.__('Livestream_source_changed_succesfully')); - }); - } else { - return toastr.error(TAPi18n.__('Livestream_url_incorrect')); - } + } + return toastr.success(TAPi18n.__('Livestream_source_changed_succesfully')); + }); }, 'click .js-popout'(e, i) { e.preventDefault(); @@ -167,5 +235,44 @@ Template.liveStreamTab.events({ onCloseCallback: () => i.popoutOpen.set(false) }); i.popoutOpen.set(true); + }, + async 'click .js-broadcast'(e, i) { + e.preventDefault(); + e.currentTarget.classList.add('loading'); + try { + const user = RocketChat.models.Users.findOne({_id: Meteor.userId()}, { fields: { 'settings.livestream': 1 }}); + if (!user.settings || !user.settings.livestream) { + await auth(); + } + const result = await call('livestreamGet', {rid: i.data.rid}); + popout.open({ + content: 'broadcastView', + data: { + ...result, + showVideoControls: false, + showStreamControls: true + }, + onCloseCallback: () => i.popoutOpen.set(false) + }); + + } catch (e) { + console.log(e); + } finally { + e.currentTarget.classList.remove('loading'); + } } }); + +RocketChat.callbacks.add('openBroadcast', (rid) => { + const roomData = Session.get(`roomData${ rid }`); + if (!roomData) { return; } + popout.open({ + content: 'liveStreamView', + data: { + streamingSource: roomData.streamingOptions.url, + isAudioOnly: roomData.streamingOptions.isAudioOnly, + showVideoControls: true, + streamingOptions: roomData.streamingOptions + } + }); +}); diff --git a/packages/rocketchat-livestream/client/views/livestreamBroadcast.html b/packages/rocketchat-livestream/client/views/livestreamBroadcast.html new file mode 100644 index 000000000000..1340a89ddf95 --- /dev/null +++ b/packages/rocketchat-livestream/client/views/livestreamBroadcast.html @@ -0,0 +1,3 @@ + diff --git a/packages/rocketchat-livestream/client/views/livestreamBroadcast.js b/packages/rocketchat-livestream/client/views/livestreamBroadcast.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/rocketchat-livestream/package.js b/packages/rocketchat-livestream/package.js index d145d7e95d55..d9e65bca854e 100644 --- a/packages/rocketchat-livestream/package.js +++ b/packages/rocketchat-livestream/package.js @@ -13,7 +13,11 @@ Package.onUse(function(api) { ]); api.addFiles([ 'client/views/liveStreamTab.html', + 'client/views/livestreamBroadcast.html', + 'client/views/livestreamBroadcast.js', 'client/views/liveStreamTab.js', + 'client/views/broadcastView.html', + 'client/views/broadcastView.js', 'client/styles/liveStreamTab.css', 'client/views/liveStreamView.html', 'client/views/liveStreamView.js', @@ -21,8 +25,13 @@ Package.onUse(function(api) { ], 'client'); api.addFiles([ + 'server/index.js', 'server/models/Rooms.js', 'server/functions/saveStreamingOptions.js', 'server/settings.js' ], 'server'); + + Npm.depends({ + 'googleapis': '25.0.0' + }); }); diff --git a/packages/rocketchat-livestream/server/functions/livestream.js b/packages/rocketchat-livestream/server/functions/livestream.js new file mode 100644 index 000000000000..7affb1e94bf6 --- /dev/null +++ b/packages/rocketchat-livestream/server/functions/livestream.js @@ -0,0 +1,151 @@ +import google from 'googleapis'; +const OAuth2 = google.auth.OAuth2; + + +const p = fn => new Promise(function(resolve, reject) { + fn(function(err, value) { + if (err) { + return reject(err); + } + resolve(value.data); + }); +}); + +export const getBroadcastStatus = async({ + id, + access_token, + refresh_token, + clientId, + clientSecret +}) => { + const auth = new OAuth2(clientId, clientSecret); + + auth.setCredentials({ + access_token, + refresh_token + }); + const youtube = google.youtube({ version:'v3', auth }); + const result = await p(resolve => youtube.liveBroadcasts.list({ + part:'id,status', + id + }, resolve)); + return result.items && result.items[0] && result.items[0].status.lifeCycleStatus; +}; + +export const statusStreamLiveStream = async({ + id, + access_token, + refresh_token, + clientId, + clientSecret +}) => { + const auth = new OAuth2(clientId, clientSecret); + + auth.setCredentials({ + access_token, + refresh_token + }); + + const youtube = google.youtube({ version:'v3', auth }); + const result = await p(resolve => youtube.liveStreams.list({ + part:'id,status', + id + }, resolve)); + return result.items && result.items[0].status.streamStatus; +}; + +export const statusLiveStream = ({ + id, + access_token, + refresh_token, + clientId, + clientSecret, + status +}) => { + const auth = new OAuth2(clientId, clientSecret); + + auth.setCredentials({ + access_token, + refresh_token + }); + + const youtube = google.youtube({ version:'v3', auth }); + + return p(resolve => youtube.liveBroadcasts.transition({ + part:'id,status', + id, + broadcastStatus: status + }, resolve)); +}; + +export const setBroadcastStatus = ({ + id, + access_token, + refresh_token, + clientId, + clientSecret, + status +}) => { + const auth = new OAuth2(clientId, clientSecret); + + auth.setCredentials({ + access_token, + refresh_token + }); + + const youtube = google.youtube({ version:'v3', auth }); + + return p(resolve => youtube.liveBroadcasts.transition({ + part:'id,status', + id, + broadcastStatus: status + }, resolve)); +}; + +export const createLiveStream = async({ + room, + access_token, + refresh_token, + clientId, + clientSecret +}) => { + const auth = new OAuth2(clientId, clientSecret); + auth.setCredentials({ + access_token, + refresh_token + }); + const youtube = google.youtube({ version:'v3', auth }); + + const [stream, broadcast] = await Promise.all([p((resolve) => youtube.liveStreams.insert({ + part: 'id,snippet,cdn,contentDetails,status', + resource: { + snippet: { + 'title': room.name || 'RocketChat Broadcast' + }, + 'cdn': { + 'format': '480p', + 'ingestionType': 'rtmp' + } + } + }, resolve)), p((resolve)=> youtube.liveBroadcasts.insert({ + part: 'id,snippet,contentDetails,status', + resource: { + snippet: { + 'title': room.name || 'RocketChat Broadcast', + 'scheduledStartTime' : new Date().toISOString() + }, + 'status': { + 'privacyStatus': 'unlisted' + } + } + }, resolve))]); + + await p(resolve => youtube.liveBroadcasts.bind({ + part: 'id,snippet,status', + // resource: { + id: broadcast.id, + streamId: stream.id + }, resolve)); + + return {id: stream.cdn.ingestionInfo.streamName, stream, broadcast}; +}; diff --git a/packages/rocketchat-livestream/server/index.js b/packages/rocketchat-livestream/server/index.js new file mode 100644 index 000000000000..63ea14561bf5 --- /dev/null +++ b/packages/rocketchat-livestream/server/index.js @@ -0,0 +1,2 @@ +import './routes.js'; +import './methods.js'; diff --git a/packages/rocketchat-livestream/server/methods.js b/packages/rocketchat-livestream/server/methods.js new file mode 100644 index 000000000000..dbee392045f4 --- /dev/null +++ b/packages/rocketchat-livestream/server/methods.js @@ -0,0 +1,141 @@ +import {Meteor} from 'meteor/meteor'; +import { createLiveStream, statusLiveStream, statusStreamLiveStream, getBroadcastStatus, setBroadcastStatus } from './functions/livestream'; + +const selectLivestreamSettings = (user) => user && user.settings && user.settings.livestream; + +Meteor.methods({ + + async livestreamStreamStatus({streamId}) { + if (!streamId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'Livestream ID not found', { + method: 'livestreamStreamStatus' + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to stream', { + method: 'livestreamStreamStatus' + }); + } + + const {access_token, refresh_token} = livestreamSettings; + + return await statusStreamLiveStream({ + id: streamId, + access_token, + refresh_token, + clientId: RocketChat.settings.get('Broadcasting_client_id'), + clientSecret: RocketChat.settings.get('Broadcasting_client_secret') + }); + + }, + async setLivestreamStatus({broadcastId, status}) { + if (!broadcastId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamStart' + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamStart' + }); + } + + const {access_token, refresh_token} = livestreamSettings; + + return await statusLiveStream({ + id: broadcastId, + access_token, + refresh_token, + status, + clientId: RocketChat.settings.get('Broadcasting_client_id'), + clientSecret: RocketChat.settings.get('Broadcasting_client_secret') + }); + + }, + async livestreamGet({rid}) { + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamGet' + }); + } + + const room = RocketChat.models.Rooms.findOne({_id: rid}); + + if (!room) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'You have no settings to livestream', { + method: 'livestreamGet' + }); + } + + const {access_token, refresh_token} = livestreamSettings; + return await createLiveStream({ + room, + access_token, + refresh_token, + clientId: RocketChat.settings.get('Broadcasting_client_id'), + clientSecret: RocketChat.settings.get('Broadcasting_client_secret') + }); + + }, + async getBroadcastStatus({broadcastId}) { + if (!broadcastId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'Broadcast ID not found', { + method: 'getBroadcastStatus' + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to stream', { + method: 'getBroadcastStatus' + }); + } + + const {access_token, refresh_token} = livestreamSettings; + + return await getBroadcastStatus({ + id: broadcastId, + access_token, + refresh_token, + clientId: RocketChat.settings.get('Broadcasting_client_id'), + clientSecret: RocketChat.settings.get('Broadcasting_client_secret') + }); + }, + async setBroadcastStatus({broadcastId, status}) { + if (!broadcastId) { + // TODO: change error + throw new Meteor.Error('error-not-allowed', 'Broadcast ID not found', { + method: 'setBroadcastStatus' + }); + } + const livestreamSettings = selectLivestreamSettings(Meteor.user()); + + if (!livestreamSettings) { + throw new Meteor.Error('error-not-allowed', 'You have no settings to stream', { + method: 'setBroadcastStatus' + }); + } + + const {access_token, refresh_token} = livestreamSettings; + + return await setBroadcastStatus({ + id: broadcastId, + access_token, + refresh_token, + status, + clientId: RocketChat.settings.get('Broadcasting_client_id'), + clientSecret: RocketChat.settings.get('Broadcasting_client_secret') + }); + + } +}); diff --git a/packages/rocketchat-livestream/server/routes.js b/packages/rocketchat-livestream/server/routes.js new file mode 100644 index 000000000000..083083c5e4d4 --- /dev/null +++ b/packages/rocketchat-livestream/server/routes.js @@ -0,0 +1,45 @@ +import google from 'googleapis'; +const OAuth2 = google.auth.OAuth2; + +RocketChat.API.v1.addRoute('livestream/oauth', { + get: function functionName() { + const clientAuth = new OAuth2(RocketChat.settings.get('Broadcasting_client_id'), RocketChat.settings.get('Broadcasting_client_secret'), `${ RocketChat.settings.get('Site_Url') }/api/v1/livestream/oauth/callback`.replace(/\/{2}api/g, '/api')); + const { userId } = this.queryParams; + const url = clientAuth.generateAuthUrl({ + access_type: 'offline', + scope: ['https://www.googleapis.com/auth/youtube'], + state: JSON.stringify({ + userId + }) + }); + + return { + statusCode: 302, + headers: { + Location: url + }, body: 'Oauth redirect' + }; + } +}); + +RocketChat.API.v1.addRoute('livestream/oauth/callback', { + get: function functionName() { + const { code, state } = this.queryParams; + + const { userId } = JSON.parse(state); + + const clientAuth = new OAuth2(RocketChat.settings.get('Broadcasting_client_id'), RocketChat.settings.get('Broadcasting_client_secret'), `${ RocketChat.settings.get('Site_Url') }/api/v1/livestream/oauth/callback`.replace(/\/{2}api/g, '/api')); + + const ret = Meteor.wrapAsync(clientAuth.getToken.bind(clientAuth))(code); + + RocketChat.models.Users.update({ _id: userId }, {$set: { + 'settings.livestream' : ret + }}); + + return { + headers: { + 'content-type' : 'text/html' + }, body: '' + }; + } +}); diff --git a/packages/rocketchat-livestream/server/settings.js b/packages/rocketchat-livestream/server/settings.js index 4ef3103d4c68..d195b6a573d6 100644 --- a/packages/rocketchat-livestream/server/settings.js +++ b/packages/rocketchat-livestream/server/settings.js @@ -1,10 +1,22 @@ Meteor.startup(function() { - RocketChat.settings.addGroup('LiveStream', function() { + RocketChat.settings.addGroup('LiveStream & Broadcasting', function() { + this.add('Livestream_enabled', false, { type: 'boolean', - i18nLabel: 'Enabled', public: true, alert: 'This feature is currently in beta! Please report bugs to github.com/RocketChat/Rocket.Chat/issues' }); + + this.add('Broadcasting_enabled', false, { + type: 'boolean', + public: true, + alert: 'This feature is currently in beta! Please report bugs to github.com/RocketChat/Rocket.Chat/issues', + enableQuery: { _id: 'Livestream_enabled', value: true } + }); + + this.add('Broadcasting_client_id', '', { type: 'string', public: false, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + this.add('Broadcasting_client_secret', '', { type: 'string', public: false, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + this.add('Broadcasting_api_key', '', { type: 'string', public: false, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); + this.add('Broadcasting_media_server_url', '', { type: 'string', public: true, enableQuery: { _id: 'Broadcasting_enabled', value: true } }); }); }); diff --git a/packages/rocketchat-theme/client/imports/components/header.css b/packages/rocketchat-theme/client/imports/components/header.css index 00be50a788b0..af2f21c43f1a 100644 --- a/packages/rocketchat-theme/client/imports/components/header.css +++ b/packages/rocketchat-theme/client/imports/components/header.css @@ -219,6 +219,25 @@ &:hover { color: var(--rc-color-link-active); } + &.live { + position: relative; + } + &.live:before { + content: ''; + position: absolute; + right: -2px; + bottom: -1px; + display: block; + width: 10px; + width: var(--sidebar-account-status-bullet-size); + height: 10px; + height: var(--sidebar-account-status-bullet-size); + border-radius: 50%; + border-radius: var(--sidebar-account-status-bullet-radius); + background-color: #f5455c; + z-index: 1; + animation: blink 1.5s ease-in-out infinite; + } } &__more { diff --git a/packages/rocketchat-theme/client/imports/components/popout.css b/packages/rocketchat-theme/client/imports/components/popout.css index 3be7f8998851..96ba48dec29d 100644 --- a/packages/rocketchat-theme/client/imports/components/popout.css +++ b/packages/rocketchat-theme/client/imports/components/popout.css @@ -1,3 +1,20 @@ +@keyframes loading { + 0% { + opacity: 0; + transform: scale(0.7); + } + 50% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.7); + } +} + + + .rc-popout { min-width: 380px; max-width: 800px; @@ -90,6 +107,24 @@ & button { font-size: 16px; margin: 0 5px; + + & > .rc-icon { + fill: currentColor; + stroke: currentColor; + } + + &.preparing { + animation: loading 2s infinite; + pointer-events: none; + } + &.broadcasting { + color: red; + } + } + & span { + text-transform: capitalize; + font-size: 15px; + line-height: 18px; } } diff --git a/packages/rocketchat-ui-flextab/client/flexTabBar.js b/packages/rocketchat-ui-flextab/client/flexTabBar.js index 335c0b1ed87d..7cba2c3cdf87 100644 --- a/packages/rocketchat-ui-flextab/client/flexTabBar.js +++ b/packages/rocketchat-ui-flextab/client/flexTabBar.js @@ -163,7 +163,7 @@ Template.RoomsActionTab.events({ $(e.currentTarget).blur(); e.preventDefault(); const buttons = RocketChat.TabBar.getButtons().filter(button => filterButtons(button, instance.anonymous, instance.data.rid)); - const groups = [{items:(instance.small.get() ? buttons : buttons.slice(4)).map(item => { + const groups = [{items:(instance.small.get() ? buttons : buttons.slice(RocketChat.TabBar.size)).map(item => { item.name = TAPi18n.__(item.i18nTitle); item.action = action; return item; @@ -175,7 +175,7 @@ Template.RoomsActionTab.events({ popoverClass: 'message-box', data: { rid: this._id, - buttons: instance.small.get() ? buttons : buttons.slice(4), + buttons: instance.small.get() ? buttons : buttons.slice(RocketChat.TabBar.size), tabBar: instance.tabBar }, currentTarget: e.currentTarget, @@ -216,7 +216,7 @@ Template.RoomsActionTab.helpers({ return []; } const buttons = RocketChat.TabBar.getButtons().filter(button => filterButtons(button, this.anonymous, this.data.rid)); - return buttons.length <= 5 ? buttons : buttons.slice(0, 4); + return buttons.length <= RocketChat.TabBar.size ? buttons : buttons.slice(0, RocketChat.TabBar.size); }, moreButtons() { @@ -226,6 +226,6 @@ Template.RoomsActionTab.helpers({ const buttons = RocketChat.TabBar.getButtons().filter(button => filterButtons(button, this.anonymous, this.data.rid) ); - return buttons.length > 5; + return buttons.length > RocketChat.TabBar.size; } }); diff --git a/packages/rocketchat-ui/client/views/app/popout.html b/packages/rocketchat-ui/client/views/app/popout.html index 33c2d9287c9d..4d2691f965c4 100644 --- a/packages/rocketchat-ui/client/views/app/popout.html +++ b/packages/rocketchat-ui/client/views/app/popout.html @@ -17,6 +17,12 @@

{{> icon icon="podcast"}}

{{/if}} {{/if}} + {{#if showStreamControls}} +
+ + {{ getStreamStatus }} +
+ {{/if}} {{#unless isAudioOnly}} + {{ getStreamStatus }} + + {{/if}} diff --git a/packages/rocketchat-ui/client/views/app/popout.js b/packages/rocketchat-ui/client/views/app/popout.js index d96111d6032b..8073eb635fc5 100644 --- a/packages/rocketchat-ui/client/views/app/popout.js +++ b/packages/rocketchat-ui/client/views/app/popout.js @@ -4,6 +4,7 @@ this.popout = { context: null, isAudioOnly: false, showVideoControls: true, + showStreamControls: false, x: 0, y: 0, open(config = {}, fn) { @@ -19,6 +20,7 @@ this.popout = { if (config.data) { this.isAudioOnly = config.data.isAudioOnly; this.showVideoControls = config.data.showVideoControls; + this.showStreamControls = config.data.showStreamControls; } }, close() { @@ -82,6 +84,12 @@ Template.popout.helpers({ }, showVideoControls() { return Template.instance().showVideoControls.get(); + }, + showStreamControls() { + return Template.instance().showStreamControls.get(); + }, + getStreamStatus() { + return Template.instance().streamStatus.get(); } }); @@ -89,6 +97,8 @@ Template.popout.onRendered(function() { Template.instance().isMinimized.set(popout.isAudioOnly); Template.instance().isAudioOnly.set(popout.isAudioOnly); Template.instance().showVideoControls.set(popout.showVideoControls); + Template.instance().showStreamControls.set(popout.showStreamControls); + if (this.data.onRendered) { this.data.onRendered(); @@ -99,8 +109,11 @@ Template.popout.onCreated(function() { this.isAudioOnly = new ReactiveVar(popout.isAudioOnly); this.canOpenExternal = new ReactiveVar(popout.canOpenExternal); this.showVideoControls = new ReactiveVar(popout.showVideoControls); + this.showStreamControls = new ReactiveVar(popout.showStreamControls); + this.isMuted = new ReactiveVar(false); this.isPlaying = new ReactiveVar(true); + this.streamStatus = new ReactiveVar('preparing'); document.body.addEventListener('dragstart', popout.dragstart, true); document.body.addEventListener('dragover', popout.dragover, true); document.body.addEventListener('dragend', popout.dragend, true); @@ -137,7 +150,7 @@ Template.popout.events({ }, 'dragstart .rc-popout-wrapper'(event) { const e = event.originalEvent || event; - const url = this.data.streamingSource || '.rc-popout-wrapper'; + const url = (this.data && this.data.streamingSource) || '.rc-popout-wrapper'; popout.x = e.offsetX; popout.y = e.offsetY; e.dataTransfer.setData('application/x-moz-node', e.currentTarget); @@ -147,6 +160,25 @@ Template.popout.events({ 'dragend .rc-popout-wrapper'(event) { event.preventDefault(); }, + 'click .rc-popout__controls--record'(e, i) { + e.preventDefault(); + if (i.streamStatus.get() === 'ready') { + document.querySelector('.streaming-popup').dispatchEvent(new Event('startStreaming')); + i.streamStatus.set('starting'); + } else if (i.streamStatus.get() === 'broadcasting') { + document.querySelector('.streaming-popup').dispatchEvent(new Event('stopStreaming')); + i.streamStatus.set('finished'); + setTimeout(() => popout && popout.close(), 2000); + } + }, + 'broadcastStreamReady .streaming-popup'(e, i) { + e.preventDefault(); + i.streamStatus.set('ready'); + }, + 'broadcastStream .streaming-popup'(e, i) { + e.preventDefault(); + i.streamStatus.set('broadcasting'); + }, 'click .rc-popout__controls--play'(e, i) { window.liveStreamPlayer.playVideo(); i.isPlaying.set(true);