diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2eae7e6933..73f40aaca1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -35,6 +35,7 @@ - [Thibault Nocchi](https://github.com/ThibaultNocchi) - [MrTimscampi](https://github.com/MrTimscampi) - [Sarab Singh](https://github.com/sarab97) + - [Andrei Oanca](https://github.com/OancaAndrei) # Emby Contributors diff --git a/gulpfile.js b/gulpfile.js index 6c33167386..538497d4d0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ const options = { query: ['src/**/*.png', 'src/**/*.jpg', 'src/**/*.gif', 'src/**/*.svg'] }, copy: { - query: ['src/**/*.json', 'src/**/*.ico'] + query: ['src/**/*.json', 'src/**/*.ico', 'src/**/*.mp3'] }, injectBundle: { query: 'src/index.html' diff --git a/package.json b/package.json index 14ff38d351..ccea0f43c7 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,10 @@ "src/components/playback/mediasession.js", "src/components/sanatizefilename.js", "src/components/scrollManager.js", + "src/components/syncplay/playbackPermissionManager.js", + "src/components/syncplay/groupSelectionMenu.js", + "src/components/syncplay/timeSyncManager.js", + "src/components/syncplay/syncPlayManager.js", "src/scripts/dfnshelper.js", "src/scripts/dom.js", "src/scripts/filesystem.js", diff --git a/src/assets/audio/silence.mp3 b/src/assets/audio/silence.mp3 new file mode 100644 index 0000000000..29dbef2185 Binary files /dev/null and b/src/assets/audio/silence.mp3 differ diff --git a/src/assets/css/videoosd.css b/src/assets/css/videoosd.css index f4f198325b..50cb41021b 100644 --- a/src/assets/css/videoosd.css +++ b/src/assets/css/videoosd.css @@ -30,7 +30,7 @@ opacity: 0; } -.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton) { +.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton):not(.headerSyncButton) { display: none; } diff --git a/src/components/htmlAudioPlayer/plugin.js b/src/components/htmlAudioPlayer/plugin.js index 000af6f226..8265987e28 100644 --- a/src/components/htmlAudioPlayer/plugin.js +++ b/src/components/htmlAudioPlayer/plugin.js @@ -177,6 +177,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp elem.addEventListener('pause', onPause); elem.addEventListener('playing', onPlaying); elem.addEventListener('play', onPlay); + elem.addEventListener('waiting', onWaiting); } function unBindEvents(elem) { @@ -186,6 +187,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp elem.removeEventListener('pause', onPause); elem.removeEventListener('playing', onPlaying); elem.removeEventListener('play', onPlay); + elem.removeEventListener('waiting', onWaiting); } self.stop = function (destroyPlayer) { @@ -300,6 +302,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp events.trigger(self, 'pause'); } + function onWaiting() { + events.trigger(self, 'waiting'); + } + function onError() { var errorCode = this.error ? (this.error.code || 0) : 0; @@ -456,6 +462,21 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp return false; }; + HtmlAudioPlayer.prototype.setPlaybackRate = function (value) { + var mediaElement = this._mediaElement; + if (mediaElement) { + mediaElement.playbackRate = value; + } + }; + + HtmlAudioPlayer.prototype.getPlaybackRate = function () { + var mediaElement = this._mediaElement; + if (mediaElement) { + return mediaElement.playbackRate; + } + return null; + }; + HtmlAudioPlayer.prototype.setVolume = function (val) { var mediaElement = this._mediaElement; if (mediaElement) { @@ -499,5 +520,26 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp }; + var supportedFeatures; + + function getSupportedFeatures() { + var list = []; + var audio = document.createElement('audio'); + + if (typeof audio.playbackRate === 'number') { + list.push('PlaybackRate'); + } + + return list; + } + + HtmlAudioPlayer.prototype.supports = function (feature) { + if (!supportedFeatures) { + supportedFeatures = getSupportedFeatures(); + } + + return supportedFeatures.indexOf(feature) !== -1; + }; + return HtmlAudioPlayer; }); diff --git a/src/components/htmlVideoPlayer/plugin.js b/src/components/htmlVideoPlayer/plugin.js index 3cf1922a3f..0c9e15146a 100644 --- a/src/components/htmlVideoPlayer/plugin.js +++ b/src/components/htmlVideoPlayer/plugin.js @@ -799,6 +799,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa videoElement.removeEventListener('play', onPlay); videoElement.removeEventListener('click', onClick); videoElement.removeEventListener('dblclick', onDblClick); + videoElement.removeEventListener('waiting', onWaiting); videoElement.parentNode.removeChild(videoElement); } @@ -927,6 +928,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa events.trigger(self, 'pause'); } + function onWaiting() { + events.trigger(self, 'waiting'); + } + function onError() { var errorCode = this.error ? (this.error.code || 0) : 0; var errorMessage = this.error ? (this.error.message || '') : ''; @@ -1349,6 +1354,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa videoElement.addEventListener('play', onPlay); videoElement.addEventListener('click', onClick); videoElement.addEventListener('dblclick', onDblClick); + videoElement.addEventListener('waiting', onWaiting); if (options.backdropUrl) { videoElement.poster = options.backdropUrl; } @@ -1436,6 +1442,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa list.push('AirPlay'); } + if (typeof video.playbackRate === 'number') { + list.push('PlaybackRate'); + } + list.push('SetBrightness'); list.push('SetAspectRatio'); @@ -1656,6 +1666,21 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa return false; }; + HtmlVideoPlayer.prototype.setPlaybackRate = function (value) { + var mediaElement = this._mediaElement; + if (mediaElement) { + mediaElement.playbackRate = value; + } + }; + + HtmlVideoPlayer.prototype.getPlaybackRate = function () { + var mediaElement = this._mediaElement; + if (mediaElement) { + return mediaElement.playbackRate; + } + return null; + }; + HtmlVideoPlayer.prototype.setVolume = function (val) { var mediaElement = this._mediaElement; if (mediaElement) { diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index dea419c8cc..59108cf72e 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -54,6 +54,7 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla if (!serverId) { // Not a server item // We can expand on this later and possibly report them + events.trigger(playbackManagerInstance, 'reportplayback', [false]); return; } @@ -77,7 +78,11 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla } var apiClient = connectionManager.getApiClient(serverId); - apiClient[method](info); + var reportPlaybackPromise = apiClient[method](info); + // Notify that report has been sent + reportPlaybackPromise.then(() => { + events.trigger(playbackManagerInstance, 'reportplayback', [true]); + }); } function getPlaylistSync(playbackManagerInstance, player) { @@ -3775,6 +3780,20 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla } }; + PlaybackManager.prototype.setPlaybackRate = function (value, player = this._currentPlayer) { + if (player && player.setPlaybackRate) { + player.setPlaybackRate(value); + } + }; + + PlaybackManager.prototype.getPlaybackRate = function (player = this._currentPlayer) { + if (player && player.getPlaybackRate) { + return player.getPlaybackRate(); + } + + return null; + }; + PlaybackManager.prototype.instantMix = function (item, player) { player = player || this._currentPlayer; @@ -3885,6 +3904,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla if (player.supports('SetAspectRatio')) { list.push('SetAspectRatio'); } + if (player.supports('PlaybackRate')) { + list.push('PlaybackRate'); + } } return list; diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index c0fb369c6c..a65baf3553 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -1,4 +1,4 @@ -define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, playMethodHelper, layoutManager, serverNotifications) { +define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncPlayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncPlayManager, playMethodHelper, layoutManager, serverNotifications) { 'use strict'; function init(instance) { @@ -327,6 +327,28 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth return sessionStats; } + function getSyncPlayStats() { + var syncStats = []; + var stats = syncPlayManager.getStats(); + + syncStats.push({ + label: globalize.translate('LabelSyncPlayTimeOffset'), + value: stats.TimeOffset + globalize.translate('MillisecondsUnit') + }); + + syncStats.push({ + label: globalize.translate('LabelSyncPlayPlaybackDiff'), + value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit') + }); + + syncStats.push({ + label: globalize.translate('LabelSyncPlaySyncMethod'), + value: stats.SyncMethod + }); + + return syncStats; + } + function getStats(instance, player) { var statsPromise = player.getStats ? player.getStats() : Promise.resolve({}); @@ -383,6 +405,13 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth name: 'Original Media Info' }); + if (syncPlayManager.isSyncPlayEnabled()) { + categories.push({ + stats: getSyncPlayStats(), + name: 'SyncPlay Info' + }); + } + return Promise.resolve(categories); }); } diff --git a/src/components/syncplay/groupSelectionMenu.js b/src/components/syncplay/groupSelectionMenu.js new file mode 100644 index 0000000000..067100ad73 --- /dev/null +++ b/src/components/syncplay/groupSelectionMenu.js @@ -0,0 +1,189 @@ +import events from 'events'; +import connectionManager from 'connectionManager'; +import playbackManager from 'playbackManager'; +import syncPlayManager from 'syncPlayManager'; +import loading from 'loading'; +import toast from 'toast'; +import actionsheet from 'actionsheet'; +import globalize from 'globalize'; +import playbackPermissionManager from 'playbackPermissionManager'; + +/** + * Gets active player id. + * @returns {string} The player's id. + */ +function getActivePlayerId () { + var info = playbackManager.getPlayerInfo(); + return info ? info.id : null; +} + +/** + * Used when user needs to join a group. + * @param {HTMLElement} button - Element where to place the menu. + * @param {Object} user - Current user. + * @param {Object} apiClient - ApiClient. + */ +function showNewJoinGroupSelection (button, user, apiClient) { + const sessionId = getActivePlayerId() || 'none'; + const inSession = sessionId !== 'none'; + const policy = user.localUser ? user.localUser.Policy : {}; + let playingItemId; + try { + const playState = playbackManager.getPlayerState(); + playingItemId = playState.NowPlayingItem.Id; + console.debug('Item', playingItemId, 'is currently playing.'); + } catch (error) { + playingItemId = ''; + console.debug('No item is currently playing.'); + } + + apiClient.sendSyncPlayCommand(sessionId, 'ListGroups').then(function (response) { + response.json().then(function (groups) { + var menuItems = groups.map(function (group) { + return { + name: group.PlayingItemName, + icon: 'group', + id: group.GroupId, + selected: false, + secondaryText: group.Participants.join(', ') + }; + }); + + if (inSession && policy.SyncPlayAccess === 'CreateAndJoinGroups') { + menuItems.push({ + name: globalize.translate('LabelSyncPlayNewGroup'), + icon: 'add', + id: 'new-group', + selected: true, + secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription') + }); + } + + if (menuItems.length === 0) { + if (inSession && policy.SyncPlayAccess === 'JoinGroups') { + toast({ + text: globalize.translate('MessageSyncPlayCreateGroupDenied') + }); + } else { + toast({ + text: globalize.translate('MessageSyncPlayNoGroupsAvailable') + }); + } + loading.hide(); + return; + } + + var menuOptions = { + title: globalize.translate('HeaderSyncPlaySelectGroup'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == 'new-group') { + apiClient.sendSyncPlayCommand(sessionId, 'NewGroup'); + } else { + apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', { + GroupId: id, + PlayingItemId: playingItemId + }); + } + }).catch((error) => { + console.error('SyncPlay: unexpected error listing groups:', error); + }); + + loading.hide(); + }); + }).catch(function (error) { + console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncPlayErrorAccessingGroups') + }); + }); +} + +/** + * Used when user has joined a group. + * @param {HTMLElement} button - Element where to place the menu. + * @param {Object} user - Current user. + * @param {Object} apiClient - ApiClient. + */ +function showLeaveGroupSelection (button, user, apiClient) { + const sessionId = getActivePlayerId(); + if (!sessionId) { + syncPlayManager.signalError(); + toast({ + text: globalize.translate('MessageSyncPlayErrorNoActivePlayer') + }); + showNewJoinGroupSelection(button, user, apiClient); + return; + } + + const menuItems = [{ + name: globalize.translate('LabelSyncPlayLeaveGroup'), + icon: 'meeting_room', + id: 'leave-group', + selected: true, + secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription') + }]; + + var menuOptions = { + title: globalize.translate('HeaderSyncPlayEnabled'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == 'leave-group') { + apiClient.sendSyncPlayCommand(sessionId, 'LeaveGroup'); + } + }).catch((error) => { + console.error('SyncPlay: unexpected error showing group menu:', error); + }); + + loading.hide(); +} + +// Register to SyncPlay events +let syncPlayEnabled = false; +events.on(syncPlayManager, 'enabled', function (e, enabled) { + syncPlayEnabled = enabled; +}); + +/** + * Shows a menu to handle SyncPlay groups. + * @param {HTMLElement} button - Element where to place the menu. + */ +export function show (button) { + loading.show(); + + // TODO: should feature be disabled if playback permission is missing? + playbackPermissionManager.check().then(() => { + console.debug('Playback is allowed.'); + }).catch((error) => { + console.error('Playback not allowed!', error); + toast({ + text: globalize.translate('MessageSyncPlayPlaybackPermissionRequired') + }); + }); + + const apiClient = connectionManager.currentApiClient(); + connectionManager.user(apiClient).then((user) => { + if (syncPlayEnabled) { + showLeaveGroupSelection(button, user, apiClient); + } else { + showNewJoinGroupSelection(button, user, apiClient); + } + }).catch((error) => { + console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncPlayNoGroupsAvailable') + }); + }); +} diff --git a/src/components/syncplay/playbackPermissionManager.js b/src/components/syncplay/playbackPermissionManager.js new file mode 100644 index 0000000000..3c258ad18d --- /dev/null +++ b/src/components/syncplay/playbackPermissionManager.js @@ -0,0 +1,51 @@ +/** + * Creates an audio element that plays a silent sound. + * @returns {HTMLMediaElement} The audio element. + */ +function createTestMediaElement () { + + const elem = document.createElement('audio'); + elem.classList.add('testMediaPlayerAudio'); + elem.classList.add('hide'); + + document.body.appendChild(elem); + + elem.volume = 1; // Volume should not be zero to trigger proper permissions + elem.src = 'assets/audio/silence.mp3'; // Silent sound + + return elem; +} + +/** + * Destroys a media element. + * @param {HTMLMediaElement} elem The element to destroy. + */ +function destroyTestMediaElement (elem) { + elem.pause(); + elem.remove(); +} + +/** + * Class that manages the playback permission. + */ +class PlaybackPermissionManager { + /** + * Tests playback permission. Grabs the permission when called inside a click event (or any other valid user interaction). + * @returns {Promise} Promise that resolves succesfully if playback permission is allowed. + */ + check () { + return new Promise((resolve, reject) => { + const media = createTestMediaElement(); + media.play().then(() => { + resolve(); + }).catch((error) => { + reject(error); + }).finally(() => { + destroyTestMediaElement(media); + }); + }); + } +} + +/** PlaybackPermissionManager singleton. */ +export default new PlaybackPermissionManager(); diff --git a/src/components/syncplay/syncPlayManager.js b/src/components/syncplay/syncPlayManager.js new file mode 100644 index 0000000000..f5c9ac446d --- /dev/null +++ b/src/components/syncplay/syncPlayManager.js @@ -0,0 +1,839 @@ +/** + * Module that manages the SyncPlay feature. + * @module components/syncplay/syncPlayManager + */ + +import events from 'events'; +import connectionManager from 'connectionManager'; +import playbackManager from 'playbackManager'; +import timeSyncManager from 'timeSyncManager'; +import toast from 'toast'; +import globalize from 'globalize'; + +/** + * Waits for an event to be triggered on an object. An optional timeout can specified after which the promise is rejected. + * @param {Object} emitter Object on which to listen for events. + * @param {string} eventType Event name to listen for. + * @param {number} timeout Time in milliseconds before rejecting promise if event does not trigger. + * @returns {Promise} A promise that resolves when the event is triggered. + */ +function waitForEventOnce(emitter, eventType, timeout) { + return new Promise((resolve, reject) => { + let rejectTimeout; + if (timeout) { + rejectTimeout = setTimeout(() => { + reject('Timed out.'); + }, timeout); + } + const callback = () => { + events.off(emitter, eventType, callback); + if (rejectTimeout) { + clearTimeout(rejectTimeout); + } + resolve(arguments); + }; + events.on(emitter, eventType, callback); + }); +} + +/** + * Gets active player id. + * @returns {string} The player's id. + */ +function getActivePlayerId() { + var info = playbackManager.getPlayerInfo(); + return info ? info.id : null; +} + +/** + * Playback synchronization + */ +const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled +const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled +const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync +const SpeedToSyncTime = 1000; // milliseconds, duration in which the playback is sped up +const MaxAttemptsSpeedToSync = 3; // attempts before disabling SpeedToSync +const MaxAttemptsSync = 5; // attempts before disabling syncing at all + +/** + * Other constants + */ +const WaitForEventDefaultTimeout = 30000; // milliseconds +const WaitForPlayerEventTimeout = 500; // milliseconds + +/** + * Class that manages the SyncPlay feature. + */ +class SyncPlayManager { + constructor() { + this.playbackRateSupported = false; + this.syncEnabled = false; + this.playbackDiffMillis = 0; // used for stats + this.syncMethod = 'None'; // used for stats + this.syncAttempts = 0; + this.lastSyncTime = new Date(); + this.syncWatcherTimeout = null; // interval that watches playback time and syncs it + + this.lastPlaybackWaiting = null; // used to determine if player's buffering + this.minBufferingThresholdMillis = 1000; + + this.currentPlayer = null; + this.localPlayerPlaybackRate = 1.0; // used to restore user PlaybackRate + + this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled + this.syncPlayReady = false; // SyncPlay is ready after first ping to server + + this.lastCommand = null; + this.queuedCommand = null; + + this.scheduledCommand = null; + this.syncTimeout = null; + + this.timeOffsetWithServer = 0; // server time minus local time + this.roundTripDuration = 0; + this.notifySyncPlayReady = false; + + events.on(playbackManager, 'playbackstart', (player, state) => { + this.onPlaybackStart(player, state); + }); + + events.on(playbackManager, 'playbackstop', (stopInfo) => { + this.onPlaybackStop(stopInfo); + }); + + events.on(playbackManager, 'playerchange', () => { + this.onPlayerChange(); + }); + + this.bindToPlayer(playbackManager.getCurrentPlayer()); + + events.on(this, 'timeupdate', (event) => { + this.syncPlaybackTime(); + }); + + events.on(timeSyncManager, 'update', (event, error, timeOffset, ping) => { + if (error) { + console.debug('SyncPlay, time update issue', error); + return; + } + + this.timeOffsetWithServer = timeOffset; + this.roundTripDuration = ping * 2; + + if (this.notifySyncPlayReady) { + this.syncPlayReady = true; + events.trigger(this, 'ready'); + this.notifySyncPlayReady = false; + } + + // Report ping + if (this.syncEnabled) { + const apiClient = connectionManager.currentApiClient(); + const sessionId = getActivePlayerId(); + + if (!sessionId) { + this.signalError(); + toast({ + text: globalize.translate('MessageSyncPlayErrorMissingSession') + }); + return; + } + + apiClient.sendSyncPlayCommand(sessionId, 'UpdatePing', { + Ping: ping + }); + } + }); + } + + /** + * Called when playback starts. + */ + onPlaybackStart (player, state) { + events.trigger(this, 'playbackstart', [player, state]); + } + + /** + * Called when playback stops. + */ + onPlaybackStop (stopInfo) { + events.trigger(this, 'playbackstop', [stopInfo]); + if (this.isSyncPlayEnabled()) { + this.disableSyncPlay(false); + } + } + + /** + * Called when the player changes. + */ + onPlayerChange () { + this.bindToPlayer(playbackManager.getCurrentPlayer()); + events.trigger(this, 'playerchange', [this.currentPlayer]); + } + + /** + * Called when playback unpauses. + */ + onPlayerUnpause () { + events.trigger(this, 'unpause', [this.currentPlayer]); + } + + /** + * Called when playback pauses. + */ + onPlayerPause() { + events.trigger(this, 'pause', [this.currentPlayer]); + } + + /** + * Called on playback progress. + * @param {Object} e The time update event. + */ + onTimeUpdate (e) { + // NOTICE: this event is unreliable, at least in Safari + // which just stops firing the event after a while. + events.trigger(this, 'timeupdate', [e]); + } + + /** + * Called when playback is resumed. + */ + onPlaying () { + // TODO: implement group wait + this.lastPlaybackWaiting = null; + events.trigger(this, 'playing'); + } + + /** + * Called when playback is buffering. + */ + onWaiting () { + // TODO: implement group wait + if (!this.lastPlaybackWaiting) { + this.lastPlaybackWaiting = new Date(); + } + events.trigger(this, 'waiting'); + } + + /** + * Gets playback buffering status. + * @returns {boolean} _true_ if player is buffering, _false_ otherwise. + */ + isBuffering () { + if (this.lastPlaybackWaiting === null) return false; + return (new Date() - this.lastPlaybackWaiting) > this.minBufferingThresholdMillis; + } + + /** + * Binds to the player's events. + * @param {Object} player The player. + */ + bindToPlayer (player) { + if (player !== this.currentPlayer) { + this.releaseCurrentPlayer(); + this.currentPlayer = player; + if (!player) return; + } + + // FIXME: the following are needed because the 'events' module + // is changing the scope when executing the callbacks. + // For instance, calling 'onPlayerUnpause' from the wrong scope breaks things because 'this' + // points to 'player' (the event emitter) instead of pointing to the SyncPlayManager singleton. + const self = this; + this._onPlayerUnpause = () => { + self.onPlayerUnpause(); + }; + + this._onPlayerPause = () => { + self.onPlayerPause(); + }; + + this._onTimeUpdate = (e) => { + self.onTimeUpdate(e); + }; + + this._onPlaying = () => { + self.onPlaying(); + }; + + this._onWaiting = () => { + self.onWaiting(); + }; + + events.on(player, 'unpause', this._onPlayerUnpause); + events.on(player, 'pause', this._onPlayerPause); + events.on(player, 'timeupdate', this._onTimeUpdate); + events.on(player, 'playing', this._onPlaying); + events.on(player, 'waiting', this._onWaiting); + this.playbackRateSupported = player.supports('PlaybackRate'); + + // Save player current PlaybackRate value + if (this.playbackRateSupported) { + this.localPlayerPlaybackRate = player.getPlaybackRate(); + } + } + + /** + * Removes the bindings to the current player's events. + */ + releaseCurrentPlayer () { + var player = this.currentPlayer; + if (player) { + events.off(player, 'unpause', this._onPlayerUnpause); + events.off(player, 'pause', this._onPlayerPause); + events.off(player, 'timeupdate', this._onTimeUpdate); + events.off(player, 'playing', this._onPlaying); + events.off(player, 'waiting', this._onWaiting); + // Restore player original PlaybackRate value + if (this.playbackRateSupported) { + player.setPlaybackRate(this.localPlayerPlaybackRate); + this.localPlayerPlaybackRate = 1.0; + } + this.currentPlayer = null; + this.playbackRateSupported = false; + } + } + + /** + * Handles a group update from the server. + * @param {Object} cmd The group update. + * @param {Object} apiClient The ApiClient. + */ + processGroupUpdate (cmd, apiClient) { + switch (cmd.Type) { + case 'PrepareSession': + this.prepareSession(apiClient, cmd.GroupId, cmd.Data); + break; + case 'UserJoined': + toast({ + text: globalize.translate('MessageSyncPlayUserJoined', cmd.Data) + }); + break; + case 'UserLeft': + toast({ + text: globalize.translate('MessageSyncPlayUserLeft', cmd.Data) + }); + break; + case 'GroupJoined': + this.enableSyncPlay(apiClient, new Date(cmd.Data), true); + break; + case 'NotInGroup': + case 'GroupLeft': + this.disableSyncPlay(true); + break; + case 'GroupWait': + toast({ + text: globalize.translate('MessageSyncPlayGroupWait', cmd.Data) + }); + break; + case 'GroupDoesNotExist': + toast({ + text: globalize.translate('MessageSyncPlayGroupDoesNotExist') + }); + break; + case 'CreateGroupDenied': + toast({ + text: globalize.translate('MessageSyncPlayCreateGroupDenied') + }); + break; + case 'JoinGroupDenied': + toast({ + text: globalize.translate('MessageSyncPlayJoinGroupDenied') + }); + break; + case 'LibraryAccessDenied': + toast({ + text: globalize.translate('MessageSyncPlayLibraryAccessDenied') + }); + break; + default: + console.error('processSyncPlayGroupUpdate: command is not recognised: ' + cmd.Type); + break; + } + } + + /** + * Handles a playback command from the server. + * @param {Object} cmd The playback command. + * @param {Object} apiClient The ApiClient. + */ + processCommand (cmd, apiClient) { + if (cmd === null) return; + + if (!this.isSyncPlayEnabled()) { + console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command', cmd); + return; + } + + if (!this.syncPlayReady) { + console.debug('SyncPlay processCommand: SyncPlay not ready, queued command', cmd); + this.queuedCommand = cmd; + return; + } + + cmd.When = new Date(cmd.When); + cmd.EmittedAt = new Date(cmd.EmitttedAt); + + if (cmd.EmitttedAt < this.syncPlayEnabledAt) { + console.debug('SyncPlay processCommand: ignoring old command', cmd); + return; + } + + // Check if new command differs from last one + if (this.lastCommand && + this.lastCommand.When === cmd.When && + this.lastCommand.PositionTicks === cmd.PositionTicks && + this.Command === cmd.Command + ) { + console.debug('SyncPlay processCommand: ignoring duplicate command', cmd); + return; + } + + this.lastCommand = cmd; + console.log('SyncPlay will', cmd.Command, 'at', cmd.When, 'PositionTicks', cmd.PositionTicks); + + switch (cmd.Command) { + case 'Play': + this.schedulePlay(cmd.When, cmd.PositionTicks); + break; + case 'Pause': + this.schedulePause(cmd.When, cmd.PositionTicks); + break; + case 'Seek': + this.scheduleSeek(cmd.When, cmd.PositionTicks); + break; + default: + console.error('processCommand: command is not recognised: ' + cmd.Type); + break; + } + } + + /** + * Prepares this client to join a group by loading the required content. + * @param {Object} apiClient The ApiClient. + * @param {string} groupId The group to join. + * @param {Object} sessionData Info about the content to load. + */ + prepareSession (apiClient, groupId, sessionData) { + const serverId = apiClient.serverInfo().Id; + playbackManager.play({ + ids: sessionData.ItemIds, + startPositionTicks: sessionData.StartPositionTicks, + mediaSourceId: sessionData.MediaSourceId, + audioStreamIndex: sessionData.AudioStreamIndex, + subtitleStreamIndex: sessionData.SubtitleStreamIndex, + startIndex: sessionData.StartIndex, + serverId: serverId + }).then(() => { + waitForEventOnce(this, 'playbackstart', WaitForEventDefaultTimeout).then(() => { + var sessionId = getActivePlayerId(); + if (!sessionId) { + console.error('Missing sessionId!'); + toast({ + text: globalize.translate('MessageSyncPlayErrorMissingSession') + }); + return; + } + // Get playing item id + let playingItemId; + try { + const playState = playbackManager.getPlayerState(); + playingItemId = playState.NowPlayingItem.Id; + } catch (error) { + playingItemId = ''; + } + // Make sure the server has received the player state + waitForEventOnce(playbackManager, 'reportplayback', WaitForEventDefaultTimeout).then((success) => { + this.localPause(); + if (!success) { + console.warning('Error reporting playback state to server. Joining group will fail.'); + } + apiClient.sendSyncPlayCommand(sessionId, 'JoinGroup', { + GroupId: groupId, + PlayingItemId: playingItemId + }); + }).catch(() => { + console.error('Timed out while waiting for `reportplayback` event!'); + toast({ + text: globalize.translate('MessageSyncPlayErrorMedia') + }); + return; + }); + }).catch(() => { + console.error('Timed out while waiting for `playbackstart` event!'); + if (!this.isSyncPlayEnabled()) { + toast({ + text: globalize.translate('MessageSyncPlayErrorMedia') + }); + } + return; + }); + }).catch((error) => { + console.error(error); + toast({ + text: globalize.translate('MessageSyncPlayErrorMedia') + }); + }); + } + + /** + * Enables SyncPlay. + * @param {Object} apiClient The ApiClient. + * @param {Date} enabledAt When SyncPlay has been enabled. Server side date. + * @param {boolean} showMessage Display message. + */ + enableSyncPlay (apiClient, enabledAt, showMessage = false) { + this.syncPlayEnabledAt = enabledAt; + this.injectPlaybackManager(); + events.trigger(this, 'enabled', [true]); + + waitForEventOnce(this, 'ready').then(() => { + this.processCommand(this.queuedCommand, apiClient); + this.queuedCommand = null; + }); + + this.syncPlayReady = false; + this.notifySyncPlayReady = true; + + timeSyncManager.forceUpdate(); + + if (showMessage) { + toast({ + text: globalize.translate('MessageSyncPlayEnabled') + }); + } + } + + /** + * Disables SyncPlay. + * @param {boolean} showMessage Display message. + */ + disableSyncPlay (showMessage = false) { + this.syncPlayEnabledAt = null; + this.syncPlayReady = false; + this.lastCommand = null; + this.queuedCommand = null; + this.syncEnabled = false; + events.trigger(this, 'enabled', [false]); + this.restorePlaybackManager(); + + if (showMessage) { + toast({ + text: globalize.translate('MessageSyncPlayDisabled') + }); + } + } + + /** + * Gets SyncPlay status. + * @returns {boolean} _true_ if user joined a group, _false_ otherwise. + */ + isSyncPlayEnabled () { + return this.syncPlayEnabledAt !== null; + } + + /** + * Schedules a resume playback on the player at the specified clock time. + * @param {Date} playAtTime The server's UTC time at which to resume playback. + * @param {number} positionTicks The PositionTicks from where to resume. + */ + schedulePlay (playAtTime, positionTicks) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const playAtTimeLocal = timeSyncManager.serverDateToLocal(playAtTime); + + if (playAtTimeLocal > currentTime) { + const playTimeout = playAtTimeLocal - currentTime; + this.localSeek(positionTicks); + + this.scheduledCommand = setTimeout(() => { + this.localUnpause(); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + }, SyncMethodThreshold / 2); + + }, playTimeout); + + console.debug('Scheduled play in', playTimeout / 1000.0, 'seconds.'); + } else { + // Group playback already started + const serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000; + waitForEventOnce(this, 'unpause').then(() => { + this.localSeek(serverPositionTicks); + }); + this.localUnpause(); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + }, SyncMethodThreshold / 2); + } + } + + /** + * Schedules a pause playback on the player at the specified clock time. + * @param {Date} pauseAtTime The server's UTC time at which to pause playback. + * @param {number} positionTicks The PositionTicks where player will be paused. + */ + schedulePause (pauseAtTime, positionTicks) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const pauseAtTimeLocal = timeSyncManager.serverDateToLocal(pauseAtTime); + + const callback = () => { + waitForEventOnce(this, 'pause', WaitForPlayerEventTimeout).then(() => { + this.localSeek(positionTicks); + }).catch(() => { + // Player was already paused, seeking + this.localSeek(positionTicks); + }); + this.localPause(); + }; + + if (pauseAtTimeLocal > currentTime) { + const pauseTimeout = pauseAtTimeLocal - currentTime; + this.scheduledCommand = setTimeout(callback, pauseTimeout); + + console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.'); + } else { + callback(); + } + } + + /** + * Schedules a seek playback on the player at the specified clock time. + * @param {Date} pauseAtTime The server's UTC time at which to seek playback. + * @param {number} positionTicks The PositionTicks where player will be seeked. + */ + scheduleSeek (seekAtTime, positionTicks) { + this.schedulePause(seekAtTime, positionTicks); + } + + /** + * Clears the current scheduled command. + */ + clearScheduledCommand () { + clearTimeout(this.scheduledCommand); + clearTimeout(this.syncTimeout); + + this.syncEnabled = false; + if (this.currentPlayer) { + this.currentPlayer.setPlaybackRate(1); + } + this.clearSyncIcon(); + } + + /** + * Overrides some PlaybackManager's methods to intercept playback commands. + */ + injectPlaybackManager () { + if (!this.isSyncPlayEnabled()) return; + if (playbackManager.syncPlayEnabled) return; + + // TODO: make this less hacky + playbackManager._localUnpause = playbackManager.unpause; + playbackManager._localPause = playbackManager.pause; + playbackManager._localSeek = playbackManager.seek; + + playbackManager.unpause = this.playRequest; + playbackManager.pause = this.pauseRequest; + playbackManager.seek = this.seekRequest; + playbackManager.syncPlayEnabled = true; + } + + /** + * Restores original PlaybackManager's methods. + */ + restorePlaybackManager () { + if (this.isSyncPlayEnabled()) return; + if (!playbackManager.syncPlayEnabled) return; + + playbackManager.unpause = playbackManager._localUnpause; + playbackManager.pause = playbackManager._localPause; + playbackManager.seek = playbackManager._localSeek; + playbackManager.syncPlayEnabled = false; + } + + /** + * Overrides PlaybackManager's unpause method. + */ + playRequest (player) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + apiClient.sendSyncPlayCommand(sessionId, 'PlayRequest'); + } + + /** + * Overrides PlaybackManager's pause method. + */ + pauseRequest (player) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + apiClient.sendSyncPlayCommand(sessionId, 'PauseRequest'); + // Pause locally as well, to give the user some little control + playbackManager._localUnpause(player); + } + + /** + * Overrides PlaybackManager's seek method. + */ + seekRequest (PositionTicks, player) { + var apiClient = connectionManager.currentApiClient(); + var sessionId = getActivePlayerId(); + apiClient.sendSyncPlayCommand(sessionId, 'SeekRequest', { + PositionTicks: PositionTicks + }); + } + + /** + * Calls original PlaybackManager's unpause method. + */ + localUnpause(player) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localUnpause(player); + } else { + playbackManager.unpause(player); + } + } + + /** + * Calls original PlaybackManager's pause method. + */ + localPause(player) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localPause(player); + } else { + playbackManager.pause(player); + } + } + + /** + * Calls original PlaybackManager's seek method. + */ + localSeek(PositionTicks, player) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSeek(PositionTicks, player); + } else { + playbackManager.seek(PositionTicks, player); + } + } + + /** + * Attempts to sync playback time with estimated server time. + * + * When sync is enabled, the following will be checked: + * - check if local playback time is close enough to the server playback time + * If it is not, then a playback time sync will be attempted. + * Two methods of syncing are available: + * - SpeedToSync: speeds up the media for some time to catch up (default is one second) + * - SkipToSync: seeks the media to the estimated correct time + * SpeedToSync aims to reduce the delay as much as possible, whereas SkipToSync is less pretentious. + */ + syncPlaybackTime () { + // Attempt to sync only when media is playing. + if (!this.lastCommand || this.lastCommand.Command !== 'Play' || this.isBuffering()) return; + + const currentTime = new Date(); + + // Avoid overloading the browser + const elapsed = currentTime - this.lastSyncTime; + if (elapsed < SyncMethodThreshold / 2) return; + this.lastSyncTime = currentTime; + + const playAtTime = this.lastCommand.When; + + const currentPositionTicks = playbackManager.currentTime(); + // Estimate PositionTicks on server + const serverPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) + this.timeOffsetWithServer) * 10000; + // Measure delay that needs to be recovered + // diff might be caused by the player internally starting the playback + const diffMillis = (serverPositionTicks - currentPositionTicks) / 10000.0; + + this.playbackDiffMillis = diffMillis; + + if (this.syncEnabled) { + const absDiffMillis = Math.abs(diffMillis); + // TODO: SpeedToSync sounds bad on songs + // TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist + if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) { + // Disable SpeedToSync if it keeps failing + if (this.syncAttempts > MaxAttemptsSpeedToSync) { + this.playbackRateSupported = false; + } + // SpeedToSync method + const speed = 1 + diffMillis / SpeedToSyncTime; + + this.currentPlayer.setPlaybackRate(speed); + this.syncEnabled = false; + this.syncAttempts++; + this.showSyncIcon('SpeedToSync (x' + speed + ')'); + + this.syncTimeout = setTimeout(() => { + this.currentPlayer.setPlaybackRate(1); + this.syncEnabled = true; + this.clearSyncIcon(); + }, SpeedToSyncTime); + } else if (absDiffMillis > MaxAcceptedDelaySkipToSync) { + // Disable SkipToSync if it keeps failing + if (this.syncAttempts > MaxAttemptsSync) { + this.syncEnabled = false; + this.showSyncIcon('Sync disabled (too many attempts)'); + } + // SkipToSync method + this.localSeek(serverPositionTicks); + this.syncEnabled = false; + this.syncAttempts++; + this.showSyncIcon('SkipToSync (' + this.syncAttempts + ')'); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + this.clearSyncIcon(); + }, SyncMethodThreshold / 2); + } else { + // Playback is synced + if (this.syncAttempts > 0) { + console.debug('Playback has been synced after', this.syncAttempts, 'attempts.'); + } + this.syncAttempts = 0; + } + } + } + + /** + * Gets SyncPlay stats. + * @returns {Object} The SyncPlay stats. + */ + getStats () { + return { + TimeOffset: this.timeOffsetWithServer, + PlaybackDiff: this.playbackDiffMillis, + SyncMethod: this.syncMethod + }; + } + + /** + * Emits an event to update the SyncPlay status icon. + */ + showSyncIcon (syncMethod) { + this.syncMethod = syncMethod; + events.trigger(this, 'syncing', [true, this.syncMethod]); + } + + /** + * Emits an event to clear the SyncPlay status icon. + */ + clearSyncIcon () { + this.syncMethod = 'None'; + events.trigger(this, 'syncing', [false, this.syncMethod]); + } + + /** + * Signals an error state, which disables and resets SyncPlay for a new session. + */ + signalError () { + this.disableSyncPlay(); + } +} + +/** SyncPlayManager singleton. */ +export default new SyncPlayManager(); diff --git a/src/components/syncplay/timeSyncManager.js b/src/components/syncplay/timeSyncManager.js new file mode 100644 index 0000000000..ca92939576 --- /dev/null +++ b/src/components/syncplay/timeSyncManager.js @@ -0,0 +1,207 @@ +/** + * Module that manages time syncing with server. + * @module components/syncplay/timeSyncManager + */ + +import events from 'events'; +import connectionManager from 'connectionManager'; + +/** + * Time estimation + */ +const NumberOfTrackedMeasurements = 8; +const PollingIntervalGreedy = 1000; // milliseconds +const PollingIntervalLowProfile = 60000; // milliseconds +const GreedyPingCount = 3; + +/** + * Class that stores measurement data. + */ +class Measurement { + /** + * Creates a new measurement. + * @param {Date} requestSent Client's timestamp of the request transmission + * @param {Date} requestReceived Server's timestamp of the request reception + * @param {Date} responseSent Server's timestamp of the response transmission + * @param {Date} responseReceived Client's timestamp of the response reception + */ + constructor(requestSent, requestReceived, responseSent, responseReceived) { + this.requestSent = requestSent.getTime(); + this.requestReceived = requestReceived.getTime(); + this.responseSent = responseSent.getTime(); + this.responseReceived = responseReceived.getTime(); + } + + /** + * Time offset from server. + */ + getOffset () { + return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2; + } + + /** + * Get round-trip delay. + */ + getDelay () { + return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived); + } + + /** + * Get ping time. + */ + getPing () { + return this.getDelay() / 2; + } +} + +/** + * Class that manages time syncing with server. + */ +class TimeSyncManager { + constructor() { + this.pingStop = true; + this.pollingInterval = PollingIntervalGreedy; + this.poller = null; + this.pings = 0; // number of pings + this.measurement = null; // current time sync + this.measurements = []; + + this.startPing(); + } + + /** + * Gets status of time sync. + * @returns {boolean} _true_ if a measurement has been done, _false_ otherwise. + */ + isReady() { + return !!this.measurement; + } + + /** + * Gets time offset with server. + * @returns {number} The time offset. + */ + getTimeOffset () { + return this.measurement ? this.measurement.getOffset() : 0; + } + + /** + * Gets ping time to server. + * @returns {number} The ping time. + */ + getPing () { + return this.measurement ? this.measurement.getPing() : 0; + } + + /** + * Updates time offset between server and client. + * @param {Measurement} measurement The new measurement. + */ + updateTimeOffset(measurement) { + this.measurements.push(measurement); + if (this.measurements.length > NumberOfTrackedMeasurements) { + this.measurements.shift(); + } + + // Pick measurement with minimum delay + const sortedMeasurements = this.measurements.slice(0); + sortedMeasurements.sort((a, b) => a.getDelay() - b.getDelay()); + this.measurement = sortedMeasurements[0]; + } + + /** + * Schedules a ping request to the server. Triggers time offset update. + */ + requestPing() { + if (!this.poller) { + this.poller = setTimeout(() => { + this.poller = null; + const apiClient = connectionManager.currentApiClient(); + const requestSent = new Date(); + apiClient.getServerTime().then((response) => { + const responseReceived = new Date(); + response.json().then((data) => { + const requestReceived = new Date(data.RequestReceptionTime); + const responseSent = new Date(data.ResponseTransmissionTime); + + const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived); + this.updateTimeOffset(measurement); + + // Avoid overloading server + if (this.pings >= GreedyPingCount) { + this.pollingInterval = PollingIntervalLowProfile; + } else { + this.pings++; + } + + events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]); + }); + }).catch((error) => { + console.error(error); + events.trigger(this, 'update', [error, null, null]); + }).finally(() => { + this.requestPing(); + }); + + }, this.pollingInterval); + } + } + + /** + * Drops accumulated measurements. + */ + resetMeasurements () { + this.measurement = null; + this.measurements = []; + } + + /** + * Starts the time poller. + */ + startPing() { + this.requestPing(); + } + + /** + * Stops the time poller. + */ + stopPing() { + if (this.poller) { + clearTimeout(this.poller); + this.poller = null; + } + } + + /** + * Resets poller into greedy mode. + */ + forceUpdate() { + this.stopPing(); + this.pollingInterval = PollingIntervalGreedy; + this.pings = 0; + this.startPing(); + } + + /** + * Converts server time to local time. + * @param {Date} server The time to convert. + * @returns {Date} Local time. + */ + serverDateToLocal(server) { + // server - local = offset + return new Date(server.getTime() - this.getTimeOffset()); + } + + /** + * Converts local time to server time. + * @param {Date} local The time to convert. + * @returns {Date} Server time. + */ + localDateToServer(local) { + // server - local = offset + return new Date(local.getTime() + this.getTimeOffset()); + } +} + +/** TimeSyncManager singleton. */ +export default new TimeSyncManager(); diff --git a/src/controllers/dashboard/users/useredit.js b/src/controllers/dashboard/users/useredit.js index 21ed60cfa9..125c571362 100644 --- a/src/controllers/dashboard/users/useredit.js +++ b/src/controllers/dashboard/users/useredit.js @@ -104,6 +104,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function $('#chkEnableSharing', page).checked(user.Policy.EnablePublicSharing); $('#txtRemoteClientBitrateLimit', page).val(user.Policy.RemoteClientBitrateLimit / 1e6 || ''); $('#txtLoginAttemptsBeforeLockout', page).val(user.Policy.LoginAttemptsBeforeLockout || '0'); + $('#selectSyncPlayAccess').val(user.Policy.SyncPlayAccess); loading.hide(); } @@ -145,6 +146,7 @@ define(['jQuery', 'loading', 'libraryMenu', 'globalize', 'fnchecked'], function }).map(function (c) { return c.getAttribute('data-id'); }); + user.Policy.SyncPlayAccess = page.querySelector('#selectSyncPlayAccess').value; ApiClient.updateUser(user).then(function () { ApiClient.updateUserPolicy(user.Id, user.Policy).then(function () { onSaveComplete(page, user); diff --git a/src/scripts/libraryMenu.js b/src/scripts/libraryMenu.js index 7cbee94f42..9e3f77b317 100644 --- a/src/scripts/libraryMenu.js +++ b/src/scripts/libraryMenu.js @@ -1,4 +1,4 @@ -define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, browser, globalize, imageHelper) { +define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncPlayManager', 'groupSelectionMenu', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncPlayManager, groupSelectionMenu, browser, globalize, imageHelper) { 'use strict'; function renderHeader() { @@ -12,6 +12,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', ' html += ''; html += '