From d8beb9909fd6b54bd1c929fa3e9e7a935d9c6e33 Mon Sep 17 00:00:00 2001 From: Ionut Andrei Oanca Date: Fri, 25 Sep 2020 09:44:30 +0200 Subject: [PATCH] Add playlist-sync and group-wait to SyncPlay --- src/assets/css/dashboard.css | 6 + src/assets/css/videoosd.css | 115 +++ src/components/playback/playbackmanager.js | 44 +- src/components/playerstats/playerstats.js | 18 +- .../playlisteditor/playlisteditor.js | 3 +- .../recordingcreator/recordinghelper.js | 1 - src/components/syncPlay/core/controller.js | 221 +++++ src/components/syncPlay/core/helper.js | 240 +++++ src/components/syncPlay/core/index.js | 13 + src/components/syncPlay/core/manager.js | 479 ++++++++++ src/components/syncPlay/core/playbackCore.js | 577 ++++++++++++ .../syncPlay/core/players/factory.js | 73 ++ .../syncPlay/core/players/genericPlayer.js | 305 +++++++ src/components/syncPlay/core/queueCore.js | 372 ++++++++ src/components/syncPlay/core/timeSync/core.js | 78 ++ .../syncPlay/core/timeSync/server.js | 39 + .../timeSync/timeSync.js} | 140 +-- src/components/syncPlay/groupSelectionMenu.js | 189 ---- src/components/syncPlay/syncPlayManager.js | 838 ------------------ .../syncPlay/ui/groupSelectionMenu.js | 189 ++++ .../{ => ui}/playbackPermissionManager.js | 0 .../syncPlay/ui/players/htmlAudioPlayer.js | 19 + .../syncPlay/ui/players/htmlVideoPlayer.js | 155 ++++ .../syncPlay/ui/players/noActivePlayer.js | 444 ++++++++++ .../syncPlay/ui/players/queueManager.js | 202 +++++ src/components/syncPlay/ui/syncPlayToasts.js | 34 + src/controllers/playback/video/index.html | 6 + src/controllers/playback/video/index.js | 106 +++ src/elements/emby-checkbox/emby-checkbox.css | 11 + src/elements/emby-checkbox/emby-checkbox.js | 4 +- src/scripts/libraryMenu.js | 10 +- src/scripts/serverNotifications.js | 6 +- src/scripts/site.js | 24 + src/strings/en-us.json | 13 +- src/themes/appletv/theme.css | 4 + src/themes/blueradiance/theme.css | 4 + src/themes/dark/theme.css | 4 + src/themes/light/theme.css | 4 + src/themes/purplehaze/theme.css | 6 + src/themes/wmc/theme.css | 4 + webpack.common.js | 5 +- 41 files changed, 3880 insertions(+), 1125 deletions(-) create mode 100644 src/components/syncPlay/core/controller.js create mode 100644 src/components/syncPlay/core/helper.js create mode 100644 src/components/syncPlay/core/index.js create mode 100644 src/components/syncPlay/core/manager.js create mode 100644 src/components/syncPlay/core/playbackCore.js create mode 100644 src/components/syncPlay/core/players/factory.js create mode 100644 src/components/syncPlay/core/players/genericPlayer.js create mode 100644 src/components/syncPlay/core/queueCore.js create mode 100644 src/components/syncPlay/core/timeSync/core.js create mode 100644 src/components/syncPlay/core/timeSync/server.js rename src/components/syncPlay/{timeSyncManager.js => core/timeSync/timeSync.js} (53%) delete mode 100644 src/components/syncPlay/groupSelectionMenu.js delete mode 100644 src/components/syncPlay/syncPlayManager.js create mode 100644 src/components/syncPlay/ui/groupSelectionMenu.js rename src/components/syncPlay/{ => ui}/playbackPermissionManager.js (100%) create mode 100644 src/components/syncPlay/ui/players/htmlAudioPlayer.js create mode 100644 src/components/syncPlay/ui/players/htmlVideoPlayer.js create mode 100644 src/components/syncPlay/ui/players/noActivePlayer.js create mode 100644 src/components/syncPlay/ui/players/queueManager.js create mode 100644 src/components/syncPlay/ui/syncPlayToasts.js diff --git a/src/assets/css/dashboard.css b/src/assets/css/dashboard.css index 48e6fe807e..7db407e2c5 100644 --- a/src/assets/css/dashboard.css +++ b/src/assets/css/dashboard.css @@ -77,6 +77,7 @@ progress[aria-valuenow]::before { height: 4em; } +.controlGroupButton, a[data-role=button] { background: #292929 !important; background-clip: padding-box; @@ -93,6 +94,7 @@ a[data-role=button] { text-decoration: none !important; } +div[data-role=controlgroup] .controlGroupButton, div[data-role=controlgroup] a[data-role=button] { display: inline-block !important; margin: 0 !important; @@ -102,6 +104,7 @@ div[data-role=controlgroup] a[data-role=button] { border-radius: 0; } +div[data-role=controlgroup] .controlGroupButton:first-child, div[data-role=controlgroup] a[data-role=button]:first-child { -webkit-border-bottom-left-radius: 0.3125em; border-bottom-left-radius: 0.3125em; @@ -109,6 +112,7 @@ div[data-role=controlgroup] a[data-role=button]:first-child { border-top-left-radius: 0.3125em; } +div[data-role=controlgroup] .controlGroupButton:last-child, div[data-role=controlgroup] a[data-role=button]:last-child { -webkit-border-bottom-right-radius: 0.3125em; border-bottom-right-radius: 0.3125em; @@ -116,11 +120,13 @@ div[data-role=controlgroup] a[data-role=button]:last-child { border-top-right-radius: 0.3125em; } +div[data-role=controlgroup] .controlGroupButton + .controlGroupButton, div[data-role=controlgroup] a[data-role=button] + a[data-role=button] { border-left-width: 0 !important; margin: 0 0 0 -0.4em !important; } +div[data-role=controlgroup] .controlGroupButton.ui-btn-active, div[data-role=controlgroup] a.ui-btn-active { background: #00a4dc !important; color: #292929 !important; diff --git a/src/assets/css/videoosd.css b/src/assets/css/videoosd.css index b2446d5d48..1c1fe2a5a5 100644 --- a/src/assets/css/videoosd.css +++ b/src/assets/css/videoosd.css @@ -255,3 +255,118 @@ display: none !important; } } + +.syncPlayContainer { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; +} + +.primary-icon { + position: absolute; + font-size: 64px; + align-self: center; +} + +.primary-icon.spin { + font-size: 76px !important; + animation: spin 2s linear infinite; +} + +.secondary-icon { + position: absolute; + font-size: 24px; +} + +.secondary-icon.centered { + font-size: 28px !important; + align-self: center; +} + +.secondary-icon.shifted { + right: 0; + bottom: 0; + font-size: 52px; +} + +.syncPlayIconCircle { + position: relative; + visibility: hidden; + display: flex; + justify-content: center; + + border-radius: 50%; + margin: 60px; + height: 96px; + width: 96px; + + color: rgba(0, 164, 220, 0); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0); + transform: scale(1); +} + +.syncPlayIconCircle.oneShotPulse { + animation: pulse 1.5s 1; +} + +.syncPlayIconCircle.infinitePulse { + animation: infinite-pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0.7); + background: rgba(0, 164, 220, 0.3); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0.3); + } + + 70% { + transform: scale(1); + color: rgba(0, 164, 220, 0); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 60px rgba(0, 164, 220, 0); + } + + 100% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0); + } +} + +@keyframes infinite-pulse { + 0% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0.7); + background: rgba(0, 164, 220, 0.3); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0.3); + } + + 70% { + transform: scale(1); + color: rgba(0, 164, 220, 0.6); + background: rgba(0, 164, 220, 0); + box-shadow: 0 0 0 60px rgba(0, 164, 220, 0); + } + + 100% { + transform: scale(0.95); + color: rgba(0, 164, 220, 0.7); + background: rgba(0, 164, 220, 0.3); + box-shadow: 0 0 0 0 rgba(0, 164, 220, 0); + } +} + +@keyframes spin { + 100% { + transform: rotate(-360deg); + } +} diff --git a/src/components/playback/playbackmanager.js b/src/components/playback/playbackmanager.js index a2e07fe8fa..6d9aebdacb 100644 --- a/src/components/playback/playbackmanager.js +++ b/src/components/playback/playbackmanager.js @@ -1876,6 +1876,9 @@ class PlaybackManager { } } + self.translateItemsForPlayback = translateItemsForPlayback; + self.getItemsForPlayback = getItemsForPlayback; + self.play = function (options) { normalizePlayOptions(options); @@ -2504,29 +2507,38 @@ class PlaybackManager { })[0]; } + self.getItemFromPlaylistItemId = function (playlistItemId) { + let item; + let itemIndex; + const playlist = self._playQueueManager.getPlaylist(); + + for (let i = 0, length = playlist.length; i < length; i++) { + if (playlist[i].PlaylistItemId === playlistItemId) { + item = playlist[i]; + itemIndex = i; + break; + } + } + + return { + Item: item, + Index: itemIndex + }; + }; + self.setCurrentPlaylistItem = function (playlistItemId, player) { player = player || self._currentPlayer; if (player && !enableLocalPlaylistManagement(player)) { return player.setCurrentPlaylistItem(playlistItemId); } - let newItem; - let newItemIndex; - const playlist = self._playQueueManager.getPlaylist(); + const newItem = self.getItemFromPlaylistItemId(playlistItemId); - for (let i = 0, length = playlist.length; i < length; i++) { - if (playlist[i].PlaylistItemId === playlistItemId) { - newItem = playlist[i]; - newItemIndex = i; - break; - } - } + if (newItem.Item) { + const newItemPlayOptions = newItem.Item.playOptions || getDefaultPlayOptions(); - if (newItem) { - const newItemPlayOptions = newItem.playOptions || getDefaultPlayOptions(); - - playInternal(newItem, newItemPlayOptions, function () { - setPlaylistState(newItem.PlaylistItemId, newItemIndex); + playInternal(newItem.Item, newItemPlayOptions, function () { + setPlaylistState(newItem.Item.PlaylistItemId, newItem.Index); }); } }; @@ -2905,6 +2917,8 @@ class PlaybackManager { } } + Events.trigger(self, 'playbackerror', [errorType]); + const displayErrorCode = 'NoCompatibleStream'; onPlaybackStopped.call(player, e, displayErrorCode); } diff --git a/src/components/playerstats/playerstats.js b/src/components/playerstats/playerstats.js index 8cad31c65e..0fe43c5c79 100644 --- a/src/components/playerstats/playerstats.js +++ b/src/components/playerstats/playerstats.js @@ -4,7 +4,7 @@ import globalize from '../../scripts/globalize'; import layoutManager from '../layoutManager'; import { playbackManager } from '../playback/playbackmanager'; import playMethodHelper from '../playback/playmethodhelper'; -import syncPlayManager from '../syncPlay/syncPlayManager'; +import SyncPlay from 'SyncPlay'; import './playerstats.css'; import ServerConnections from '../ServerConnections'; @@ -342,16 +342,22 @@ import ServerConnections from '../ServerConnections'; function getSyncPlayStats() { const syncStats = []; - const stats = syncPlayManager.getStats(); + const stats = SyncPlay.Manager.getStats(); syncStats.push({ - label: globalize.translate('LabelSyncPlayTimeOffset'), - value: stats.TimeOffset + globalize.translate('MillisecondsUnit') + label: globalize.translate('LabelSyncPlayTimeSyncDevice'), + value: stats.TimeSyncDevice + }); + + syncStats.push({ + // TODO: clean old string 'LabelSyncPlayTimeOffset' from translations. + label: globalize.translate('LabelSyncPlayTimeSyncOffset'), + value: stats.TimeSyncOffset + ' ' + globalize.translate('MillisecondsUnit') }); syncStats.push({ label: globalize.translate('LabelSyncPlayPlaybackDiff'), - value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit') + value: stats.PlaybackDiff + ' ' + globalize.translate('MillisecondsUnit') }); syncStats.push({ @@ -433,7 +439,7 @@ import ServerConnections from '../ServerConnections'; }); const apiClient = ServerConnections.getApiClient(playbackManager.currentItem(player).ServerId); - if (syncPlayManager.isSyncPlayEnabled() && apiClient.isMinServerVersion('10.6.0')) { + if (SyncPlay.Manager.isSyncPlayEnabled() && apiClient.isMinServerVersion('10.6.0')) { categories.push({ stats: getSyncPlayStats(), name: globalize.translate('LabelSyncPlayInfo') diff --git a/src/components/playlisteditor/playlisteditor.js b/src/components/playlisteditor/playlisteditor.js index 72bc5ec0cb..c8350d9994 100644 --- a/src/components/playlisteditor/playlisteditor.js +++ b/src/components/playlisteditor/playlisteditor.js @@ -3,6 +3,7 @@ import dialogHelper from '../dialogHelper/dialogHelper'; import loading from '../loading/loading'; import layoutManager from '../layoutManager'; import { playbackManager } from '../playback/playbackmanager'; +import SyncPlay from 'SyncPlay'; import * as userSettings from '../../scripts/settings/userSettings'; import { appRouter } from '../appRouter'; import globalize from '../../scripts/globalize'; @@ -117,7 +118,7 @@ import ServerConnections from '../ServerConnections'; apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => { let html = ''; - if (editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) { + if ((editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) || SyncPlay.Manager.isSyncPlayEnabled()) { html += ``; } diff --git a/src/components/recordingcreator/recordinghelper.js b/src/components/recordingcreator/recordinghelper.js index 1e999ce35c..e837fa1f27 100644 --- a/src/components/recordingcreator/recordinghelper.js +++ b/src/components/recordingcreator/recordinghelper.js @@ -180,4 +180,3 @@ export default { cancelTimerWithConfirmation: cancelTimerWithConfirmation, cancelSeriesTimerWithConfirmation: cancelSeriesTimerWithConfirmation }; - diff --git a/src/components/syncPlay/core/controller.js b/src/components/syncPlay/core/controller.js new file mode 100644 index 0000000000..9920b0bf8b --- /dev/null +++ b/src/components/syncPlay/core/controller.js @@ -0,0 +1,221 @@ +/** + * Module that exposes SyncPlay calls to external modules. + * @module components/syncPlay/core/controller + */ + +import * as Helper from './helper'; + +/** + * Class that exposes SyncPlay calls to external modules. + */ +class SyncPlayController { + constructor() { + this.manager = null; + } + + /** + * Initializes the controller. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + } + + /** + * Toggles playback status in SyncPlay group. + */ + playPause() { + if (this.manager.isPlaying()) { + this.pause(); + } else { + this.unpause(); + } + } + + /** + * Unpauses playback in SyncPlay group. + */ + unpause() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayUnpause(); + } + + /** + * Pauses playback in SyncPlay group. + */ + pause() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayPause(); + + // Pause locally as well, to give the user some little control. + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPause(); + } + + /** + * Seeks playback to specified position in SyncPlay group. + * @param {number} positionTicks The position. + */ + seek(positionTicks) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySeek({ + PositionTicks: positionTicks + }); + } + + /** + * Starts playback in SyncPlay group. + * @param {Object} options The play data. + */ + play(options) { + const apiClient = this.manager.getApiClient(); + const sendPlayRequest = (items) => { + const queue = items.map(item => item.Id); + apiClient.requestSyncPlayPlay({ + PlayingQueue: queue.join(','), + PlayingItemPosition: options.startIndex ? options.startIndex : 0, + StartPositionTicks: options.startPositionTicks ? options.startPositionTicks : 0 + }); + }; + + if (options.items) { + Helper.translateItemsForPlayback(apiClient, options.items, options).then(sendPlayRequest); + } else { + Helper.getItemsForPlayback(apiClient, { + Ids: options.ids.join(',') + }).then(function (result) { + Helper.translateItemsForPlayback(apiClient, result.Items, options).then(sendPlayRequest); + }); + } + } + + /** + * Sets current playing item in SyncPlay group. + * @param {string} playlistItemId The item playlist identifier. + */ + setCurrentPlaylistItem(playlistItemId) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetPlaylistItem({ + PlaylistItemId: playlistItemId + }); + } + + /** + * Removes items from SyncPlay group playlist. + * @param {Array} playlistItemIds The items to remove. + */ + removeFromPlaylist(playlistItemIds) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayRemoveFromPlaylist({ + PlaylistItemIds: playlistItemIds + }); + } + + /** + * Moves an item in the SyncPlay group playlist. + * @param {string} playlistItemId The item playlist identifier. + * @param {number} newIndex The new position. + */ + movePlaylistItem(playlistItemId, newIndex) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayMovePlaylistItem({ + PlaylistItemId: playlistItemId, + NewIndex: newIndex + }); + } + + /** + * Adds items to the SyncPlay group playlist. + * @param {Object} options The items to add. + * @param {string} mode The queue mode, optional. + */ + queue(options, mode = 'Queue') { + const apiClient = this.manager.getApiClient(); + if (options.items) { + Helper.translateItemsForPlayback(apiClient, options.items, options).then((items) => { + const itemIds = items.map(item => item.Id); + apiClient.requestSyncPlayQueue({ + ItemIds: itemIds.join(','), + Mode: mode + }); + }); + } else { + Helper.getItemsForPlayback(apiClient, { + Ids: options.ids.join(',') + }).then(function (result) { + Helper.translateItemsForPlayback(apiClient, result.Items, options).then((items) => { + const itemIds = items.map(item => item.Id); + apiClient.requestSyncPlayQueue({ + ItemIds: itemIds.join(','), + Mode: mode + }); + }); + }); + } + } + + /** + * Adds items to the SyncPlay group playlist after the playing item. + * @param {Object} options The items to add. + */ + queueNext(options) { + this.queue(options, 'QueueNext'); + } + + /** + * Plays next track from playlist in SyncPlay group. + */ + nextTrack() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayNextTrack({ + PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId() + }); + } + + /** + * Plays previous track from playlist in SyncPlay group. + */ + previousTrack() { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayPreviousTrack({ + PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId() + }); + } + + /** + * Sets the repeat mode in SyncPlay group. + * @param {string} mode The repeat mode. + */ + setRepeatMode(mode) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetRepeatMode({ + Mode: mode + }); + } + + /** + * Sets the shuffle mode in SyncPlay group. + * @param {string} mode The shuffle mode. + */ + setShuffleMode(mode) { + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetShuffleMode({ + Mode: mode + }); + } + + /** + * Toggles the shuffle mode in SyncPlay group. + */ + toggleShuffleMode() { + let mode = this.manager.getQueueCore().getShuffleMode(); + mode = mode === 'Sorted' ? 'Shuffle' : 'Sorted'; + + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlaySetShuffleMode({ + Mode: mode + }); + } +} + +export default SyncPlayController; diff --git a/src/components/syncPlay/core/helper.js b/src/components/syncPlay/core/helper.js new file mode 100644 index 0000000000..f1724a5e6a --- /dev/null +++ b/src/components/syncPlay/core/helper.js @@ -0,0 +1,240 @@ +/** + * Module that offers some utility functions. + * @module components/syncPlay/core/helper + */ + +import { Events } from 'jellyfin-apiclient'; + +/** + * Constants + */ +export const WaitForEventDefaultTimeout = 30000; // milliseconds +export const WaitForPlayerEventTimeout = 500; // milliseconds +export const TicksPerMillisecond = 10000.0; + +/** + * 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 before rejecting promise if event does not trigger, in milliseconds. + * @param {Array} rejectEventTypes Event names to listen for and abort the waiting. + * @returns {Promise} A promise that resolves when the event is triggered. + */ +export function waitForEventOnce(emitter, eventType, timeout, rejectEventTypes) { + return new Promise((resolve, reject) => { + let rejectTimeout; + if (timeout) { + rejectTimeout = setTimeout(() => { + reject('Timed out.'); + }, timeout); + } + + const clearAll = () => { + Events.off(emitter, eventType, callback); + + if (rejectTimeout) { + clearTimeout(rejectTimeout); + } + + if (Array.isArray(rejectEventTypes)) { + rejectEventTypes.forEach(eventName => { + Events.off(emitter, eventName, rejectCallback); + }); + } + }; + + const callback = () => { + clearAll(); + resolve(arguments); + }; + + const rejectCallback = (event) => { + clearAll(); + reject(event.type); + }; + + Events.on(emitter, eventType, callback); + + if (Array.isArray(rejectEventTypes)) { + rejectEventTypes.forEach(eventName => { + Events.on(emitter, eventName, rejectCallback); + }); + } + }); +} + +/** + * Converts a given string to a Guid string. + * @param {string} input The input string. + * @returns {string} The Guid string. + */ +export function stringToGuid(input) { + return input.replace(/([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/, '$1-$2-$3-$4-$5'); +} + +/** + * Triggers a show-message event. + * @param {Object} syncPlayManager The SyncPlay manager. + * @param {string} message The message name. + * @param {Array} args Extra data needed for the message, optional. + */ +export function showMessage(syncPlayManager, message, args = []) { + Events.trigger(syncPlayManager, 'show-message', [{ + message: message, + args: args + }]); +} + +export function getItemsForPlayback(apiClient, query) { + if (query.Ids && query.Ids.split(',').length === 1) { + const itemId = query.Ids.split(','); + + return apiClient.getItem(apiClient.getCurrentUserId(), itemId).then(function (item) { + return { + Items: [item], + TotalRecordCount: 1 + }; + }); + } else { + query.Limit = query.Limit || 300; + query.Fields = 'Chapters'; + query.ExcludeLocationTypes = 'Virtual'; + query.EnableTotalRecordCount = false; + query.CollapseBoxSetItems = false; + + return apiClient.getItems(apiClient.getCurrentUserId(), query); + } +} + +function mergePlaybackQueries(obj1, obj2) { + const query = Object.assign(obj1, obj2); + + const filters = query.Filters ? query.Filters.split(',') : []; + if (filters.indexOf('IsNotFolder') === -1) { + filters.push('IsNotFolder'); + } + query.Filters = filters.join(','); + return query; +} + +export function translateItemsForPlayback(apiClient, items, options) { + if (items.length > 1 && options && options.ids) { + // Use the original request id array for sorting the result in the proper order. + items.sort(function (a, b) { + return options.ids.indexOf(a.Id) - options.ids.indexOf(b.Id); + }); + } + + const firstItem = items[0]; + let promise; + + const queryOptions = options.queryOptions || {}; + + if (firstItem.Type === 'Program') { + promise = getItemsForPlayback(apiClient, { + Ids: firstItem.ChannelId + }); + } else if (firstItem.Type === 'Playlist') { + promise = getItemsForPlayback(apiClient, { + ParentId: firstItem.Id, + SortBy: options.shuffle ? 'Random' : null + }); + } else if (firstItem.Type === 'MusicArtist') { + promise = getItemsForPlayback(apiClient, { + ArtistIds: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.MediaType === 'Photo') { + promise = getItemsForPlayback(apiClient, { + ParentId: firstItem.ParentId, + Filters: 'IsNotFolder', + // Setting this to true may cause some incorrect sorting. + Recursive: false, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Photo,Video' + }).then(function (result) { + const items = result.Items; + + let index = items.map(function (i) { + return i.Id; + }).indexOf(firstItem.Id); + + if (index === -1) { + index = 0; + } + + options.startIndex = index; + + return Promise.resolve(result); + }); + } else if (firstItem.Type === 'PhotoAlbum') { + promise = getItemsForPlayback(apiClient, { + ParentId: firstItem.Id, + Filters: 'IsNotFolder', + // Setting this to true may cause some incorrect sorting. + Recursive: false, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Photo,Video', + Limit: 1000 + }); + } else if (firstItem.Type === 'MusicGenre') { + promise = getItemsForPlayback(apiClient, { + GenreIds: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + SortBy: options.shuffle ? 'Random' : 'SortName', + MediaTypes: 'Audio' + }); + } else if (firstItem.IsFolder) { + promise = getItemsForPlayback(apiClient, mergePlaybackQueries({ + ParentId: firstItem.Id, + Filters: 'IsNotFolder', + Recursive: true, + // These are pre-sorted. + SortBy: options.shuffle ? 'Random' : (['BoxSet'].indexOf(firstItem.Type) === -1 ? 'SortName' : null), + MediaTypes: 'Audio,Video' + }, queryOptions)); + } else if (firstItem.Type === 'Episode' && items.length === 1) { + promise = new Promise(function (resolve, reject) { + apiClient.getCurrentUser().then(function (user) { + if (!user.Configuration.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) { + resolve(null); + return; + } + + apiClient.getEpisodes(firstItem.SeriesId, { + IsVirtualUnaired: false, + IsMissing: false, + UserId: apiClient.getCurrentUserId(), + Fields: 'Chapters' + }).then(function (episodesResult) { + let foundItem = false; + episodesResult.Items = episodesResult.Items.filter(function (e) { + if (foundItem) { + return true; + } + if (e.Id === firstItem.Id) { + foundItem = true; + return true; + } + + return false; + }); + episodesResult.TotalRecordCount = episodesResult.Items.length; + resolve(episodesResult); + }, reject); + }); + }); + } + + if (promise) { + return promise.then(function (result) { + return result ? result.Items : items; + }); + } else { + return Promise.resolve(items); + } +} diff --git a/src/components/syncPlay/core/index.js b/src/components/syncPlay/core/index.js new file mode 100644 index 0000000000..2d2f3f1777 --- /dev/null +++ b/src/components/syncPlay/core/index.js @@ -0,0 +1,13 @@ +import * as Helper from './helper'; +import Manager from './manager'; +import PlayerFactory from './players/factory'; +import GenericPlayer from './players/genericPlayer'; + +export default { + Helper, + Manager, + PlayerFactory, + Players: { + GenericPlayer + } +}; diff --git a/src/components/syncPlay/core/manager.js b/src/components/syncPlay/core/manager.js new file mode 100644 index 0000000000..b3ca1d4b21 --- /dev/null +++ b/src/components/syncPlay/core/manager.js @@ -0,0 +1,479 @@ +/** + * Module that manages the SyncPlay feature. + * @module components/syncPlay/core/manager + */ + +import { Events } from 'jellyfin-apiclient'; +import * as Helper from './helper'; +import PlayerFactory from './players/factory'; +import TimeSyncCore from './timeSync/core'; +import SyncPlayPlaybackCore from './playbackCore'; +import SyncPlayQueueCore from './queueCore'; +import SyncPlayController from './controller'; + +/** + * Class that manages the SyncPlay feature. + */ +class SyncPlayManager { + constructor() { + this.apiClient = null; + + this.timeSyncCore = new TimeSyncCore(); + this.playbackCore = new SyncPlayPlaybackCore(); + this.queueCore = new SyncPlayQueueCore(); + this.controller = new SyncPlayController(); + + this.syncMethod = 'None'; // Used for stats. + + this.groupInfo = null; + this.syncPlayEnabledAt = null; // Server time of when SyncPlay has been enabled. + this.syncPlayReady = false; // SyncPlay is ready after first ping to server. + this.queuedCommand = null; // Queued playback command, applied when SyncPlay is ready. + this.followingGroupPlayback = true; // Follow or ignore group playback. + this.lastPlaybackCommand = null; // Last received playback command from server, tracks state of group. + + this.currentPlayer = null; + this.playerWrapper = null; + } + + /** + * Initialise SyncPlay. + * @param {Object} apiClient The ApiClient. + */ + init(apiClient) { + if (!apiClient) { + throw new Error('ApiClient is null!'); + } + + // Set ApiClient. + this.apiClient = apiClient; + + // Get default player wrapper. + this.playerWrapper = PlayerFactory.getDefaultWrapper(this); + + // Initialize components. + this.timeSyncCore.init(this); + this.playbackCore.init(this); + this.queueCore.init(this); + this.controller.init(this); + + Events.on(this.timeSyncCore, 'time-sync-server-update', (event, timeOffset, ping) => { + // Report ping back to server. + if (this.syncEnabled) { + this.getApiClient().sendSyncPlayPing({ + Ping: ping + }); + } + }); + } + + /** + * Gets the time sync core. + * @returns {TimeSyncCore} The time sync core. + */ + getTimeSyncCore() { + return this.timeSyncCore; + } + + /** + * Gets the playback core. + * @returns {SyncPlayPlaybackCore} The playback core. + */ + getPlaybackCore() { + return this.playbackCore; + } + + /** + * Gets the queue core. + * @returns {SyncPlayQueueCore} The queue core. + */ + getQueueCore() { + return this.queueCore; + } + + /** + * Gets the controller used to manage SyncPlay playback. + * @returns {SyncPlayController} The controller. + */ + getController() { + return this.controller; + } + + /** + * Gets the player wrapper used to control local playback. + * @returns {SyncPlayGenericPlayer} The player wrapper. + */ + getPlayerWrapper() { + return this.playerWrapper; + } + + /** + * Gets the ApiClient used to communicate with the server. + * @returns {Object} The ApiClient. + */ + getApiClient() { + return this.apiClient; + } + + /** + * Gets the last playback command, if any. + * @returns {Object} The playback command. + */ + getLastPlaybackCommand() { + return this.lastPlaybackCommand; + } + + /** + * Called when the player changes. + */ + onPlayerChange(newPlayer, newTarget, oldPlayer) { + this.bindToPlayer(newPlayer); + } + + /** + * Binds to the player's events. + * @param {Object} player The player. + */ + bindToPlayer(player) { + this.releaseCurrentPlayer(); + + if (!player) { + return; + } + + this.playerWrapper.unbindFromPlayer(); + + this.currentPlayer = player; + this.playerWrapper = PlayerFactory.getWrapper(player, this); + + if (this.isSyncPlayEnabled()) { + this.playerWrapper.bindToPlayer(); + } + + Events.trigger(this, 'playerchange', [this.currentPlayer]); + } + + /** + * Removes the bindings from the current player's events. + */ + releaseCurrentPlayer() { + this.currentPlayer = null; + this.playerWrapper.unbindFromPlayer(); + + this.playerWrapper = PlayerFactory.getDefaultWrapper(this); + if (this.isSyncPlayEnabled()) { + this.playerWrapper.bindToPlayer(); + } + + Events.trigger(this, 'playerchange', [this.currentPlayer]); + } + + /** + * 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 'PlayQueue': + this.queueCore.updatePlayQueue(apiClient, cmd.Data); + break; + case 'UserJoined': + Helper.showMessage(this, 'MessageSyncPlayUserJoined', [cmd.Data]); + break; + case 'UserLeft': + Helper.showMessage(this, 'MessageSyncPlayUserLeft', [cmd.Data]); + break; + case 'GroupJoined': + cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt); + this.enableSyncPlay(apiClient, cmd.Data, true); + break; + case 'SyncPlayIsDisabled': + Helper.showMessage(this, 'MessageSyncPlayIsDisabled'); + break; + case 'NotInGroup': + case 'GroupLeft': + this.disableSyncPlay(true); + break; + case 'GroupUpdate': + cmd.Data.LastUpdatedAt = new Date(cmd.Data.LastUpdatedAt); + this.groupInfo = cmd.Data; + break; + case 'StateUpdate': + Events.trigger(this, 'group-state-update', [cmd.Data.State, cmd.Data.Reason]); + console.debug(`SyncPlay processGroupUpdate: state changed to ${cmd.Data.State} because ${cmd.Data.Reason}.`); + break; + case 'GroupDoesNotExist': + Helper.showMessage(this, 'MessageSyncPlayGroupDoesNotExist'); + break; + case 'CreateGroupDenied': + Helper.showMessage(this, 'MessageSyncPlayCreateGroupDenied'); + break; + case 'JoinGroupDenied': + Helper.showMessage(this, 'MessageSyncPlayJoinGroupDenied'); + break; + case 'LibraryAccessDenied': + Helper.showMessage(this, 'MessageSyncPlayLibraryAccessDenied'); + break; + default: + console.error(`SyncPlay processGroupUpdate: command ${cmd.Type} not recognised.`); + 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 (typeof cmd.When === 'string') { + cmd.When = new Date(cmd.When); + cmd.EmittedAt = new Date(cmd.EmittedAt); + cmd.PositionTicks = cmd.PositionTicks ? parseInt(cmd.PositionTicks) : null; + } + + if (!this.isSyncPlayEnabled()) { + console.debug('SyncPlay processCommand: SyncPlay not enabled, ignoring command.', cmd); + return; + } + + if (cmd.EmittedAt.getTime() < this.syncPlayEnabledAt.getTime()) { + console.debug('SyncPlay processCommand: ignoring old command.', cmd); + return; + } + + if (!this.syncPlayReady) { + console.debug('SyncPlay processCommand: SyncPlay not ready, queued command.', cmd); + this.queuedCommand = cmd; + return; + } + + this.lastPlaybackCommand = cmd; + + if (!this.isPlaybackActive()) { + console.debug('SyncPlay processCommand: no active player!'); + return; + } + + // Make sure command matches playing item in playlist. + const playlistItemId = this.queueCore.getCurrentPlaylistItemId(); + if (cmd.PlaylistItemId !== playlistItemId && cmd.Command !== 'Stop') { + console.error('SyncPlay processCommand: playlist item does not match!', cmd); + return; + } + + console.log(`SyncPlay will ${cmd.Command} at ${cmd.When} (in ${cmd.When.getTime() - Date.now()} ms)${cmd.PositionTicks ? '' : ' from ' + cmd.PositionTicks}.`); + + this.playbackCore.applyCommand(cmd); + } + + /** + * Handles a group state change. + * @param {Object} update The group state update. + * @param {Object} apiClient The ApiClient. + */ + processStateChange(update, apiClient) { + if (update === null || update.State === null || update.Reason === null) return; + + if (!this.isSyncPlayEnabled()) { + console.debug('SyncPlay processStateChange: SyncPlay not enabled, ignoring group state update.', update); + return; + } + + Events.trigger(this, 'group-state-change', [update.State, update.Reason]); + } + + /** + * Notifies server that this client is following group's playback. + * @param {Object} apiClient The ApiClient. + * @returns {Promise} A Promise fulfilled upon request completion. + */ + followGroupPlayback(apiClient) { + this.followingGroupPlayback = true; + + return apiClient.requestSyncPlaySetIgnoreWait({ + IgnoreWait: false + }); + } + + /** + * Starts this client's playback and loads the group's play queue. + * @param {Object} apiClient The ApiClient. + */ + resumeGroupPlayback(apiClient) { + this.followGroupPlayback(apiClient).then(() => { + this.queueCore.startPlayback(apiClient); + }); + } + + /** + * Stops this client's playback and notifies server to be ignored in group wait. + * @param {Object} apiClient The ApiClient. + */ + haltGroupPlayback(apiClient) { + this.followingGroupPlayback = false; + + apiClient.requestSyncPlaySetIgnoreWait({ + IgnoreWait: true + }); + this.playbackCore.localStop(); + } + + /** + * Whether this client is following group playback. + * @returns {boolean} _true_ if client should play group's content, _false_ otherwise. + */ + isFollowingGroupPlayback() { + return this.followingGroupPlayback; + } + + /** + * Enables SyncPlay. + * @param {Object} apiClient The ApiClient. + * @param {Object} groupInfo The joined group's info. + * @param {boolean} showMessage Display message. + */ + enableSyncPlay(apiClient, groupInfo, showMessage = false) { + if (this.isSyncPlayEnabled()) { + if (groupInfo.GroupId === this.groupInfo.GroupId) { + console.debug(`SyncPlay enableSyncPlay: group ${this.groupInfo.GroupId} already joined.`); + return; + } else { + console.warn(`SyncPlay enableSyncPlay: switching from group ${this.groupInfo.GroupId} to group ${groupInfo.GroupId}.`); + this.disableSyncPlay(false); + } + + showMessage = false; + } + + this.groupInfo = groupInfo; + + this.syncPlayEnabledAt = groupInfo.LastUpdatedAt; + this.playerWrapper.bindToPlayer(); + + Events.trigger(this, 'enabled', [true]); + + // Wait for time sync to be ready. + Helper.waitForEventOnce(this.timeSyncCore, 'time-sync-server-update').then(() => { + this.syncPlayReady = true; + this.processCommand(this.queuedCommand, apiClient); + this.queuedCommand = null; + }); + + this.syncPlayReady = false; + this.followingGroupPlayback = true; + + this.timeSyncCore.forceUpdate(); + + if (showMessage) { + Helper.showMessage(this, 'MessageSyncPlayEnabled'); + } + } + + /** + * Disables SyncPlay. + * @param {boolean} showMessage Display message. + */ + disableSyncPlay(showMessage = false) { + this.syncPlayEnabledAt = null; + this.syncPlayReady = false; + this.followingGroupPlayback = true; + this.lastPlaybackCommand = null; + this.queuedCommand = null; + this.playbackCore.syncEnabled = false; + Events.trigger(this, 'enabled', [false]); + this.playerWrapper.unbindFromPlayer(); + + if (showMessage) { + Helper.showMessage(this, 'MessageSyncPlayDisabled'); + } + } + + /** + * Gets SyncPlay status. + * @returns {boolean} _true_ if user joined a group, _false_ otherwise. + */ + isSyncPlayEnabled() { + return this.syncPlayEnabledAt !== null; + } + + /** + * Gets the group information. + * @returns {Object} The group information, null if SyncPlay is disabled. + */ + getGroupInfo() { + return this.groupInfo; + } + + /** + * Gets SyncPlay stats. + * @returns {Object} The SyncPlay stats. + */ + getStats() { + return { + TimeSyncDevice: this.timeSyncCore.getActiveDeviceName(), + TimeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2), + PlaybackDiff: this.playbackCore.playbackDiffMillis.toFixed(2), + SyncMethod: this.syncMethod + }; + } + + /** + * Gets playback status. + * @returns {boolean} Whether a player is active. + */ + isPlaybackActive() { + return this.playerWrapper.isPlaybackActive(); + } + + /** + * Whether the player is remotely self-managed. + * @returns {boolean} _true_ if the player is remotely self-managed, _false_ otherwise. + */ + isRemote() { + return this.playerWrapper.isRemote(); + } + + /** + * Checks if playlist is empty. + * @returns {boolean} _true_ if playlist is empty, _false_ otherwise. + */ + isPlaylistEmpty() { + return this.queueCore.isPlaylistEmpty(); + } + + /** + * Checks if playback is unpaused. + * @returns {boolean} _true_ if media is playing, _false_ otherwise. + */ + isPlaying() { + if (!this.lastPlaybackCommand) { + return false; + } else { + return this.lastPlaybackCommand.Command === 'Unpause'; + } + } + + /** + * 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]); + } +} + +/** SyncPlayManager singleton. */ +const syncPlayManager = new SyncPlayManager(); +export default syncPlayManager; diff --git a/src/components/syncPlay/core/playbackCore.js b/src/components/syncPlay/core/playbackCore.js new file mode 100644 index 0000000000..3d0ffed594 --- /dev/null +++ b/src/components/syncPlay/core/playbackCore.js @@ -0,0 +1,577 @@ +/** + * Module that manages the playback of SyncPlay. + * @module components/syncPlay/core/playbackCore + */ + +import { Events } from 'jellyfin-apiclient'; +import * as Helper from './helper'; + +/** + * Class that manages the playback of SyncPlay. + */ +class SyncPlayPlaybackCore { + constructor() { + this.manager = null; + this.timeSyncCore = null; + + this.syncEnabled = false; + this.playbackDiffMillis = 0; // Used for stats and remote time sync. + this.syncAttempts = 0; + this.lastSyncTime = new Date(); + this.enableSyncCorrection = true; // User setting to disable sync during playback. + + this.playerIsBuffering = false; + + this.lastCommand = null; // Last scheduled playback command, might not be the latest one. + this.scheduledCommandTimeout = null; + this.syncTimeout = null; + } + + /** + * Initializes the core. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + this.timeSyncCore = syncPlayManager.getTimeSyncCore(); + + // Minimum required delay for SpeedToSync to kick in, in milliseconds. + this.minDelaySpeedToSync = 60.0; + + // Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds. + this.maxDelaySpeedToSync = 3000.0; + + // Time during which the playback is sped up, in milliseconds. + this.speedToSyncDuration = 1000.0; + + // Minimum required delay for SkipToSync to kick in, in milliseconds. + this.minDelaySkipToSync = 400.0; + + // Whether SpeedToSync should be used. + this.useSpeedToSync = true; + + // Whether SkipToSync should be used. + this.useSkipToSync = true; + + // Whether sync correction during playback is active. + this.enableSyncCorrection = true; + } + + /** + * Called by player wrapper when playback starts. + */ + onPlaybackStart(player, state) { + Events.trigger(this.manager, 'playbackstart', [player, state]); + } + + /** + * Called by player wrapper when playback stops. + */ + onPlaybackStop(stopInfo) { + this.lastCommand = null; + Events.trigger(this.manager, 'playbackstop', [stopInfo]); + this.manager.releaseCurrentPlayer(); + } + + /** + * Called by player wrapper when playback unpauses. + */ + onUnpause() { + Events.trigger(this.manager, 'unpause'); + } + + /** + * Called by player wrapper when playback pauses. + */ + onPause() { + Events.trigger(this.manager, 'pause'); + } + + /** + * Called by player wrapper on playback progress. + * @param {Object} event The time update event. + * @param {Object} timeUpdateData The time update data. + */ + onTimeUpdate(event, timeUpdateData) { + this.syncPlaybackTime(timeUpdateData); + Events.trigger(this.manager, 'timeupdate', [event, timeUpdateData]); + } + + /** + * Called by player wrapper when player is ready to play. + */ + onReady() { + this.playerIsBuffering = false; + this.sendBufferingRequest(false); + Events.trigger(this.manager, 'ready'); + } + + /** + * Called by player wrapper when player is buffering. + */ + onBuffering() { + this.playerIsBuffering = true; + this.sendBufferingRequest(true); + Events.trigger(this.manager, 'buffering'); + } + + /** + * Sends a buffering request to the server. + * @param {boolean} isBuffering Whether this client is buffering or not. + */ + sendBufferingRequest(isBuffering = true) { + const playerWrapper = this.manager.getPlayerWrapper(); + const currentPosition = playerWrapper.currentTime(); + const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + + const currentTime = new Date(); + const now = this.timeSyncCore.localDateToRemote(currentTime); + const playlistItemId = this.manager.getQueueCore().getCurrentPlaylistItemId(); + + const apiClient = this.manager.getApiClient(); + apiClient.requestSyncPlayBuffering({ + When: now.toISOString(), + PositionTicks: currentPositionTicks, + IsPlaying: isPlaying, + PlaylistItemId: playlistItemId, + BufferingDone: !isBuffering + }); + } + + /** + * Gets playback buffering status. + * @returns {boolean} _true_ if player is buffering, _false_ otherwise. + */ + isBuffering() { + return this.playerIsBuffering; + } + + /** + * Applies a command and checks the playback state if a duplicate command is received. + * @param {Object} command The playback command. + */ + applyCommand(command) { + // Check if duplicate. + if (this.lastCommand && + this.lastCommand.When.getTime() === command.When.getTime() && + this.lastCommand.PositionTicks === command.PositionTicks && + this.lastCommand.Command === command.Command && + this.lastCommand.PlaylistItemId === command.PlaylistItemId + ) { + // Duplicate command found, check playback state and correct if needed. + console.debug('SyncPlay applyCommand: duplicate command received!', command); + + // Determine if past command or future one. + const currentTime = new Date(); + const whenLocal = this.timeSyncCore.remoteDateToLocal(command.When); + if (whenLocal > currentTime) { + // Command should be already scheduled, not much we can do. + // TODO: should re-apply or just drop? + console.debug('SyncPlay applyCommand: command already scheduled.', command); + return; + } else { + // Check if playback state matches requested command. + const playerWrapper = this.manager.getPlayerWrapper(); + const currentPositionTicks = Math.round(playerWrapper.currentTime() * Helper.TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + + switch (command.Command) { + case 'Unpause': + // Check playback state only, as position ticks will be corrected by sync. + if (!isPlaying) { + this.scheduleUnpause(command.When, command.PositionTicks); + } + break; + case 'Pause': + // FIXME: check range instead of fixed value for ticks. + if (isPlaying || currentPositionTicks !== command.PositionTicks) { + this.schedulePause(command.When, command.PositionTicks); + } + break; + case 'Stop': + if (isPlaying) { + this.scheduleStop(command.When); + } + break; + case 'Seek': + // During seek, playback is paused. + // FIXME: check range instead of fixed value for ticks. + if (isPlaying || currentPositionTicks !== command.PositionTicks) { + // Account for player imperfections, we got half a second of tollerance we can play with + // (the server tollerates a range of values when client reports that is ready). + const rangeWidth = 100; // In milliseconds. + const randomOffsetTicks = Math.round((Math.random() - 0.5) * rangeWidth) * Helper.TicksPerMillisecond; + this.scheduleSeek(command.When, command.PositionTicks + randomOffsetTicks); + console.debug('SyncPlay applyCommand: adding random offset to force seek:', randomOffsetTicks, command); + } else { + // All done, I guess? + this.sendBufferingRequest(false); + } + break; + default: + console.error('SyncPlay applyCommand: command is not recognised:', command); + break; + } + + // All done. + return; + } + } + + // Applying command. + this.lastCommand = command; + + // Ignore if remote player has local SyncPlay manager. + if (this.manager.isRemote()) { + return; + } + + switch (command.Command) { + case 'Unpause': + this.scheduleUnpause(command.When, command.PositionTicks); + break; + case 'Pause': + this.schedulePause(command.When, command.PositionTicks); + break; + case 'Stop': + this.scheduleStop(command.When); + break; + case 'Seek': + this.scheduleSeek(command.When, command.PositionTicks); + break; + default: + console.error('SyncPlay applyCommand: command is not recognised:', command); + break; + } + } + + /** + * 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. + */ + scheduleUnpause(playAtTime, positionTicks) { + this.clearScheduledCommand(); + const enableSyncTimeout = this.maxDelaySpeedToSync / 2.0; + const currentTime = new Date(); + const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime); + + const playerWrapper = this.manager.getPlayerWrapper(); + const currentPositionTicks = playerWrapper.currentTime() * Helper.TicksPerMillisecond; + + if (playAtTimeLocal > currentTime) { + const playTimeout = playAtTimeLocal - currentTime; + + // Seek only if delay is noticeable. + if ((currentPositionTicks - positionTicks) > this.minDelaySkipToSync * Helper.TicksPerMillisecond) { + this.localSeek(positionTicks); + } + + this.scheduledCommandTimeout = setTimeout(() => { + this.localUnpause(); + Events.trigger(this.manager, 'notify-osd', ['unpause']); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + }, enableSyncTimeout); + }, playTimeout); + + console.debug('Scheduled unpause in', playTimeout / 1000.0, 'seconds.'); + } else { + // Group playback already started. + const serverPositionTicks = this.estimateCurrentTicks(positionTicks, playAtTime); + Helper.waitForEventOnce(this.manager, 'unpause').then(() => { + this.localSeek(serverPositionTicks); + }); + this.localUnpause(); + setTimeout(() => { + Events.trigger(this.manager, 'notify-osd', ['unpause']); + }, 100); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + }, enableSyncTimeout); + + console.debug(`SyncPlay scheduleUnpause: unpause now from ${serverPositionTicks} (was at ${currentPositionTicks}).`); + } + } + + /** + * 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 = this.timeSyncCore.remoteDateToLocal(pauseAtTime); + + const callback = () => { + Helper.waitForEventOnce(this.manager, 'pause', Helper.WaitForPlayerEventTimeout).then(() => { + this.localSeek(positionTicks); + }).catch(() => { + // Player was already paused, seeking. + this.localSeek(positionTicks); + }); + this.localPause(); + }; + + if (pauseAtTimeLocal > currentTime) { + const pauseTimeout = pauseAtTimeLocal - currentTime; + this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout); + + console.debug('Scheduled pause in', pauseTimeout / 1000.0, 'seconds.'); + } else { + callback(); + console.debug('SyncPlay schedulePause: now.'); + } + } + + /** + * Schedules a stop playback on the player at the specified clock time. + * @param {Date} stopAtTime The server's UTC time at which to stop playback. + */ + scheduleStop(stopAtTime) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime); + + const callback = () => { + this.localStop(); + }; + + if (stopAtTimeLocal > currentTime) { + const stopTimeout = stopAtTimeLocal - currentTime; + this.scheduledCommandTimeout = setTimeout(callback, stopTimeout); + + console.debug('Scheduled stop in', stopTimeout / 1000.0, 'seconds.'); + } else { + callback(); + console.debug('SyncPlay scheduleStop: now.'); + } + } + + /** + * Schedules a seek playback on the player at the specified clock time. + * @param {Date} seekAtTime The server's UTC time at which to seek playback. + * @param {number} positionTicks The PositionTicks where player will be seeked. + */ + scheduleSeek(seekAtTime, positionTicks) { + this.clearScheduledCommand(); + const currentTime = new Date(); + const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime); + + const callback = () => { + this.localUnpause(); + this.localSeek(positionTicks); + + Helper.waitForEventOnce(this.manager, 'ready', Helper.WaitForEventDefaultTimeout).then(() => { + this.localPause(); + this.sendBufferingRequest(false); + }).catch((error) => { + console.error(`Timed out while waiting for 'ready' event! Seeking to ${positionTicks}.`, error); + this.localSeek(positionTicks); + }); + }; + + if (seekAtTimeLocal > currentTime) { + const seekTimeout = seekAtTimeLocal - currentTime; + this.scheduledCommandTimeout = setTimeout(callback, seekTimeout); + + console.debug('Scheduled seek in', seekTimeout / 1000.0, 'seconds.'); + } else { + callback(); + console.debug('SyncPlay scheduleSeek: now.'); + } + } + + /** + * Clears the current scheduled command. + */ + clearScheduledCommand() { + clearTimeout(this.scheduledCommandTimeout); + clearTimeout(this.syncTimeout); + + this.syncEnabled = false; + const playerWrapper = this.manager.getPlayerWrapper(); + if (playerWrapper.hasPlaybackRate()) { + playerWrapper.setPlaybackRate(1.0); + } + + this.manager.clearSyncIcon(); + } + + /** + * Unpauses the local player. + */ + localUnpause() { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localUnpause: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localUnpause(); + } + + /** + * Pauses the local player. + */ + localPause() { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localPause: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localPause(); + } + + /** + * Seeks the local player. + */ + localSeek(positionTicks) { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localSeek: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localSeek(positionTicks); + } + + /** + * Stops the local player. + */ + localStop() { + // Ignore command when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay localStop: no active player!'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + return playerWrapper.localStop(); + } + + /** + * Estimates current value for ticks given a past state. + * @param {number} ticks The value of the ticks. + * @param {Date} when The point in time for the value of the ticks. + * @param {Date} currentTime The current time, optional. + */ + estimateCurrentTicks(ticks, when, currentTime = new Date()) { + const remoteTime = this.timeSyncCore.localDateToRemote(currentTime); + return ticks + (remoteTime.getTime() - when.getTime()) * Helper.TicksPerMillisecond; + } + + /** + * Attempts to sync playback time with estimated server time (or selected device for time sync). + * + * When sync is enabled, the following will be checked: + * - check if local playback time is close enough to the server playback time; + * - playback diff (distance from estimated server playback time) is aligned with selected device for time sync. + * If playback diff exceeds some set thresholds, then a playback time sync will be attempted. + * Two strategies 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. + * @param {Object} timeUpdateData The time update data that contains the current time as date and the current position in milliseconds. + */ + syncPlaybackTime(timeUpdateData) { + // See comments in constants section for more info. + const syncMethodThreshold = this.maxDelaySpeedToSync; + let speedToSyncTime = this.speedToSyncDuration; + + // Ignore sync when no player is active. + if (!this.manager.isPlaybackActive()) { + console.debug('SyncPlay syncPlaybackTime: no active player!'); + return; + } + + // Attempt to sync only when media is playing. + const { lastCommand } = this; + + if (!lastCommand || lastCommand.Command !== 'Unpause' || this.isBuffering()) return; + + const { currentTime, currentPosition } = timeUpdateData; + + // Get current PositionTicks. + const currentPositionTicks = currentPosition * Helper.TicksPerMillisecond; + + // Estimate PositionTicks on server. + const serverPositionTicks = this.estimateCurrentTicks(lastCommand.PositionTicks, lastCommand.When, currentTime); + + // Measure delay that needs to be recovered. + // Diff might be caused by the player internally starting the playback. + const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond; + + this.playbackDiffMillis = diffMillis; + + // Avoid overloading the browser. + const elapsed = currentTime - this.lastSyncTime; + if (elapsed < syncMethodThreshold / 2) return; + + this.lastSyncTime = currentTime; + const playerWrapper = this.manager.getPlayerWrapper(); + + if (this.syncEnabled && this.enableSyncCorrection) { + 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. + // TODO: both SpeedToSync and SpeedToSync seem to have a hard time keeping up on Android Chrome as well. + if (playerWrapper.hasPlaybackRate() && this.useSpeedToSync && absDiffMillis >= this.minDelaySpeedToSync && absDiffMillis < this.maxDelaySpeedToSync) { + // Fix negative speed when client is ahead of time more than speedToSyncTime. + const MinSpeed = 0.2; + if (diffMillis <= -speedToSyncTime * MinSpeed) { + speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed); + } + + // SpeedToSync strategy. + const speed = 1 + diffMillis / speedToSyncTime; + + if (speed <= 0) { + console.error('SyncPlay error: speed should not be negative!', speed, diffMillis, speedToSyncTime); + } + + playerWrapper.setPlaybackRate(speed); + this.syncEnabled = false; + this.syncAttempts++; + this.manager.showSyncIcon(`SpeedToSync (x${speed.toFixed(2)})`); + + this.syncTimeout = setTimeout(() => { + playerWrapper.setPlaybackRate(1.0); + this.syncEnabled = true; + this.manager.clearSyncIcon(); + }, speedToSyncTime); + + console.log('SyncPlay SpeedToSync', speed); + } else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) { + // SkipToSync strategy. + this.localSeek(serverPositionTicks); + this.syncEnabled = false; + this.syncAttempts++; + this.manager.showSyncIcon(`SkipToSync (${this.syncAttempts})`); + + this.syncTimeout = setTimeout(() => { + this.syncEnabled = true; + this.manager.clearSyncIcon(); + }, syncMethodThreshold / 2); + + console.log('SyncPlay SkipToSync', serverPositionTicks); + } else { + // Playback is synced. + if (this.syncAttempts > 0) { + console.debug('Playback has been synced after', this.syncAttempts, 'attempts.'); + } + this.syncAttempts = 0; + } + } + } +} + +export default SyncPlayPlaybackCore; diff --git a/src/components/syncPlay/core/players/factory.js b/src/components/syncPlay/core/players/factory.js new file mode 100644 index 0000000000..f24ab6ae71 --- /dev/null +++ b/src/components/syncPlay/core/players/factory.js @@ -0,0 +1,73 @@ +/** + * Module that creates wrappers for known players. + * @module components/syncPlay/core/players/factory + */ + +import SyncPlayGenericPlayer from './genericPlayer'; + +/** + * Class that creates wrappers for known players. + */ +class SyncPlayPlayerFactory { + constructor() { + this.wrappers = {}; + this.DefaultWrapper = SyncPlayGenericPlayer; + } + + /** + * Registers a wrapper to the list of players that can be managed. + * @param {SyncPlayGenericPlayer} wrapperClass The wrapper to register. + */ + registerWrapper(wrapperClass) { + console.debug('SyncPlay WrapperFactory registerWrapper:', wrapperClass.type); + this.wrappers[wrapperClass.type] = wrapperClass; + } + + /** + * Sets the default player wrapper. + * @param {SyncPlayGenericPlayer} wrapperClass The wrapper. + */ + setDefaultWrapper(wrapperClass) { + console.debug('SyncPlay WrapperFactory setDefaultWrapper:', wrapperClass.type); + this.DefaultWrapper = wrapperClass; + } + + /** + * Gets a player wrapper that manages the given player. Default wrapper is used for unknown players. + * @param {Object} player The player to handle. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + * @returns The player wrapper. + */ + getWrapper(player, syncPlayManager) { + if (!player) { + console.debug('SyncPlay WrapperFactory getWrapper: using default wrapper.'); + return this.getDefaultWrapper(syncPlayManager); + } + + console.debug('SyncPlay WrapperFactory getWrapper:', player.id); + const Wrapper = this.wrappers[player.id]; + if (Wrapper) { + return new Wrapper(player, syncPlayManager); + } + + console.debug(`SyncPlay WrapperFactory getWrapper: unknown player ${player.id}, using default wrapper.`); + return this.getDefaultWrapper(syncPlayManager); + } + + /** + * Gets the default player wrapper. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + * @returns The default player wrapper. + */ + getDefaultWrapper(syncPlayManager) { + if (this.DefaultWrapper) { + return new this.DefaultWrapper(null, syncPlayManager); + } else { + return null; + } + } +} + +/** SyncPlayPlayerFactory singleton. */ +const playerFactory = new SyncPlayPlayerFactory(); +export default playerFactory; diff --git a/src/components/syncPlay/core/players/genericPlayer.js b/src/components/syncPlay/core/players/genericPlayer.js new file mode 100644 index 0000000000..3dd0e79fca --- /dev/null +++ b/src/components/syncPlay/core/players/genericPlayer.js @@ -0,0 +1,305 @@ +/** + * Module that translates events from a player to SyncPlay events. + * @module components/syncPlay/core/players/genericPlayer + */ + +import { Events } from 'jellyfin-apiclient'; + +/** + * Class that translates events from a player to SyncPlay events. + */ +class SyncPlayGenericPlayer { + static type = 'generic'; + + constructor(player, syncPlayManager) { + this.player = player; + this.manager = syncPlayManager; + this.playbackCore = syncPlayManager.getPlaybackCore(); + this.queueCore = syncPlayManager.getQueueCore(); + this.bound = false; + } + + /** + * Binds to the player's events. + */ + bindToPlayer() { + if (this.bound) { + return; + } + + this.localBindToPlayer(); + this.bound = true; + } + + /** + * Binds to the player's events. Overriden. + */ + localBindToPlayer() { + throw new Error('Override this method!'); + } + + /** + * Removes the bindings from the player's events. + */ + unbindFromPlayer() { + if (!this.bound) { + return; + } + + this.localUnbindFromPlayer(); + this.bound = false; + } + + /** + * Removes the bindings from the player's events. Overriden. + */ + localUnbindFromPlayer() { + throw new Error('Override this method!'); + } + + /** + * Called when playback starts. + */ + onPlaybackStart(player, state) { + this.playbackCore.onPlaybackStart(player, state); + Events.trigger(this, 'playbackstart', [player, state]); + } + + /** + * Called when playback stops. + */ + onPlaybackStop(stopInfo) { + this.playbackCore.onPlaybackStop(stopInfo); + Events.trigger(this, 'playbackstop', [stopInfo]); + } + + /** + * Called when playback unpauses. + */ + onUnpause() { + this.playbackCore.onUnpause(); + Events.trigger(this, 'unpause', [this.currentPlayer]); + } + + /** + * Called when playback pauses. + */ + onPause() { + this.playbackCore.onPause(); + Events.trigger(this, 'pause', [this.currentPlayer]); + } + + /** + * Called on playback progress. + * @param {Object} event The time update event. + * @param {Object} timeUpdateData The time update data. + */ + onTimeUpdate(event, timeUpdateData) { + this.playbackCore.onTimeUpdate(event, timeUpdateData); + Events.trigger(this, 'timeupdate', [event, timeUpdateData]); + } + + /** + * Called when player is ready to resume playback. + */ + onReady() { + this.playbackCore.onReady(); + Events.trigger(this, 'ready'); + } + + /** + * Called when player is buffering. + */ + onBuffering() { + this.playbackCore.onBuffering(); + Events.trigger(this, 'buffering'); + } + + /** + * Called when changes are made to the play queue. + */ + onQueueUpdate() { + // Do nothing. + } + + /** + * Gets player status. + * @returns {boolean} Whether the player has some media loaded. + */ + isPlaybackActive() { + return false; + } + + /** + * Gets playback status. + * @returns {boolean} Whether the playback is unpaused. + */ + isPlaying() { + return false; + } + + /** + * Gets playback position. + * @returns {number} The player position, in milliseconds. + */ + currentTime() { + return 0; + } + + /** + * Checks if player has playback rate support. + * @returns {boolean} _true _ if playback rate is supported, false otherwise. + */ + hasPlaybackRate() { + return false; + } + + /** + * Sets the playback rate, if supported. + * @param {number} value The playback rate. + */ + setPlaybackRate(value) { + // Do nothing. + } + + /** + * Gets the playback rate. + * @returns {number} The playback rate. + */ + getPlaybackRate() { + return 1.0; + } + + /** + * Checks if player is remotely self-managed. + * @returns {boolean} _true_ if the player is remotely self-managed, _false_ otherwise. + */ + isRemote() { + return false; + } + + /** + * Unpauses the player. + */ + localUnpause() { + + } + + /** + * Pauses the player. + */ + localPause() { + + } + + /** + * Seeks the player to the specified position. + * @param {number} positionTicks The new position. + */ + localSeek(positionTicks) { + + } + + /** + * Stops the player. + */ + localStop() { + + } + + /** + * Sends a command to the player. + * @param {Object} command The command. + */ + localSendCommand(command) { + + } + + /** + * Starts playback. + * @param {Object} options Playback data. + */ + localPlay(options) { + + } + + /** + * Sets playing item from playlist. + * @param {string} playlistItemId The item to play. + */ + localSetCurrentPlaylistItem(playlistItemId) { + + } + + /** + * Removes items from playlist. + * @param {Array} playlistItemIds The items to remove. + */ + localRemoveFromPlaylist(playlistItemIds) { + + } + + /** + * Moves an item in the playlist. + * @param {string} playlistItemId The item to move. + * @param {number} newIndex The new position. + */ + localMovePlaylistItem(playlistItemId, newIndex) { + + } + + /** + * Queues in the playlist. + * @param {Object} options Queue data. + */ + localQueue(options) { + + } + + /** + * Queues after the playing item in the playlist. + * @param {Object} options Queue data. + */ + localQueueNext(options) { + + } + + /** + * Picks next item in playlist. + */ + localNextTrack() { + + } + + /** + * Picks previous item in playlist. + */ + localPreviousTrack() { + + } + + /** + * Sets repeat mode. + * @param {string} value The repeat mode. + */ + localSetRepeatMode(value) { + + } + + /** + * Sets shuffle mode. + * @param {string} value The shuffle mode. + */ + localSetQueueShuffleMode(value) { + + } + + /** + * Toggles shuffle mode. + */ + localToggleQueueShuffleMode() { + + } +} + +export default SyncPlayGenericPlayer; diff --git a/src/components/syncPlay/core/queueCore.js b/src/components/syncPlay/core/queueCore.js new file mode 100644 index 0000000000..f31013026c --- /dev/null +++ b/src/components/syncPlay/core/queueCore.js @@ -0,0 +1,372 @@ +/** + * Module that manages the queue of SyncPlay. + * @module components/syncPlay/core/queueCore + */ + +import * as Helper from './helper'; + +/** + * Class that manages the queue of SyncPlay. + */ +class SyncPlayQueueCore { + constructor() { + this.manager = null; + this.lastPlayQueueUpdate = null; + this.playlist = []; + } + + /** + * Initializes the core. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + } + + /** + * Handles the change in the play queue. + * @param {Object} apiClient The ApiClient. + * @param {Object} newPlayQueue The new play queue. + */ + updatePlayQueue(apiClient, newPlayQueue) { + newPlayQueue.LastUpdate = new Date(newPlayQueue.LastUpdate); + + if (newPlayQueue.LastUpdate.getTime() <= this.getLastUpdateTime()) { + console.debug('SyncPlay updatePlayQueue: ignoring old update', newPlayQueue); + return; + } + + console.debug('SyncPlay updatePlayQueue:', newPlayQueue); + + const serverId = apiClient.serverInfo().Id; + + this.onPlayQueueUpdate(apiClient, newPlayQueue, serverId).then((previous) => { + if (newPlayQueue.LastUpdate.getTime() < this.getLastUpdateTime()) { + console.warn('SyncPlay updatePlayQueue: trying to apply old update.', newPlayQueue); + throw new Error('Trying to apply old update'); + } + + // Ignore if remote player is self-managed (has own SyncPlay manager running). + if (this.manager.isRemote()) { + console.warn('SyncPlay updatePlayQueue: remote player has own SyncPlay manager.'); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + + switch (newPlayQueue.Reason) { + case 'NewPlaylist': { + if (!this.manager.isFollowingGroupPlayback()) { + this.manager.followGroupPlayback(apiClient).then(() => { + this.startPlayback(apiClient); + }); + } else { + this.startPlayback(apiClient); + } + break; + } + case 'SetCurrentItem': + case 'NextTrack': + case 'PreviousTrack': { + playerWrapper.onQueueUpdate(); + + const playlistItemId = this.getCurrentPlaylistItemId(); + this.setCurrentPlaylistItem(apiClient, playlistItemId); + break; + } + case 'RemoveItems': { + playerWrapper.onQueueUpdate(); + + const index = previous.playQueueUpdate.PlayingItemIndex; + const oldPlaylistItemId = index === -1 ? null : previous.playlist[index].PlaylistItemId; + const playlistItemId = this.getCurrentPlaylistItemId(); + if (oldPlaylistItemId !== playlistItemId) { + this.setCurrentPlaylistItem(apiClient, playlistItemId); + } + break; + } + case 'MoveItem': + case 'Queue': + case 'QueueNext': { + playerWrapper.onQueueUpdate(); + break; + } + case 'RepeatMode': + playerWrapper.localSetRepeatMode(this.getRepeatMode()); + break; + case 'ShuffleMode': + playerWrapper.localSetQueueShuffleMode(this.getShuffleMode()); + break; + default: + console.error('SyncPlay updatePlayQueue: unknown reason for update:', newPlayQueue.Reason); + break; + } + }).catch((error) => { + console.warn('SyncPlay updatePlayQueue:', error); + }); + } + + /** + * Called when a play queue update needs to be applied. + * @param {Object} apiClient The ApiClient. + * @param {Object} playQueueUpdate The play queue update. + * @param {string} serverId The server identifier. + * @returns {Promise} A promise that gets resolved when update is applied. + */ + onPlayQueueUpdate(apiClient, playQueueUpdate, serverId) { + const oldPlayQueueUpdate = this.lastPlayQueueUpdate; + const oldPlaylist = this.playlist; + + const itemIds = playQueueUpdate.Playlist.map(queueItem => queueItem.ItemId); + + if (!itemIds.length) { + if (this.lastPlayQueueUpdate && playQueueUpdate.LastUpdate.getTime() <= this.getLastUpdateTime()) { + return Promise.reject('Trying to apply old update'); + } + + this.lastPlayQueueUpdate = playQueueUpdate; + this.playlist = []; + + return Promise.resolve({ + playQueueUpdate: oldPlayQueueUpdate, + playlist: oldPlaylist + }); + } + + return Helper.getItemsForPlayback(apiClient, { + Ids: itemIds.join(',') + }).then((result) => { + return Helper.translateItemsForPlayback(apiClient, result.Items, { + ids: itemIds, + serverId: serverId + }).then((items) => { + if (this.lastPlayQueueUpdate && playQueueUpdate.LastUpdate.getTime() <= this.getLastUpdateTime()) { + throw new Error('Trying to apply old update'); + } + + for (let i = 0; i < items.length; i++) { + items[i].PlaylistItemId = playQueueUpdate.Playlist[i].PlaylistItemId; + } + + this.lastPlayQueueUpdate = playQueueUpdate; + this.playlist = items; + + return { + playQueueUpdate: oldPlayQueueUpdate, + playlist: oldPlaylist + }; + }); + }); + } + + /** + * Sends a SyncPlayBuffering request on playback start. + * @param {Object} apiClient The ApiClient. + * @param {string} origin The origin of the wait call, used for debug. + */ + scheduleReadyRequestOnPlaybackStart(apiClient, origin) { + Helper.waitForEventOnce(this.manager, 'playbackstart', Helper.WaitForEventDefaultTimeout, ['playbackerror']).then(() => { + console.debug('SyncPlay scheduleReadyRequestOnPlaybackStart: local pause and notify server.'); + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPause(); + + const currentTime = new Date(); + const now = this.manager.timeSyncCore.localDateToRemote(currentTime); + const currentPosition = playerWrapper.currentTime(); + const currentPositionTicks = Math.round(currentPosition * Helper.TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + + apiClient.requestSyncPlayBuffering({ + When: now.toISOString(), + PositionTicks: currentPositionTicks, + IsPlaying: isPlaying, + PlaylistItemId: this.getCurrentPlaylistItemId(), + BufferingDone: true + }); + }).catch((error) => { + console.error('Error while waiting for `playbackstart` event!', origin, error); + if (!this.manager.isSyncPlayEnabled()) { + Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia'); + } + + this.manager.haltGroupPlayback(apiClient); + return; + }); + } + + /** + * Prepares this client for playback by loading the group's content. + * @param {Object} apiClient The ApiClient. + */ + startPlayback(apiClient) { + if (!this.manager.isFollowingGroupPlayback()) { + console.debug('SyncPlay startPlayback: ignoring, not following playback.'); + return Promise.reject(); + } + + if (this.isPlaylistEmpty()) { + console.debug('SyncPlay startPlayback: empty playlist.'); + return; + } + + // Estimate start position ticks from last playback command, if available. + const playbackCommand = this.manager.getLastPlaybackCommand(); + let startPositionTicks = 0; + + if (playbackCommand && playbackCommand.EmittedAt.getTime() >= this.getLastUpdateTime()) { + // Prefer playback commands as they're more frequent (and also because playback position is PlaybackCore's concern). + startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(playbackCommand.PositionTicks, playbackCommand.When); + } else { + // A PlayQueueUpdate is emited only on queue changes so it's less reliable for playback position syncing. + const oldStartPositionTicks = this.getStartPositionTicks(); + const lastQueueUpdateDate = this.getLastUpdate(); + startPositionTicks = this.manager.getPlaybackCore().estimateCurrentTicks(oldStartPositionTicks, lastQueueUpdateDate); + } + + const serverId = apiClient.serverInfo().Id; + + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPlay({ + ids: this.getPlaylistAsItemIds(), + startPositionTicks: startPositionTicks, + startIndex: this.getCurrentPlaylistIndex(), + serverId: serverId + }).then(() => { + this.scheduleReadyRequestOnPlaybackStart(apiClient, 'startPlayback'); + }).catch((error) => { + console.error(error); + Helper.showMessage(this.manager, 'MessageSyncPlayErrorMedia'); + }); + } + + /** + * Sets the current playing item. + * @param {Object} apiClient The ApiClient. + * @param {string} playlistItemId The playlist id of the item to play. + */ + setCurrentPlaylistItem(apiClient, playlistItemId) { + if (!this.manager.isFollowingGroupPlayback()) { + console.debug('SyncPlay setCurrentPlaylistItem: ignoring, not following playback.'); + return; + } + + this.scheduleReadyRequestOnPlaybackStart(apiClient, 'setCurrentPlaylistItem'); + + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localSetCurrentPlaylistItem(playlistItemId); + } + + /** + * Gets the index of the current playing item. + * @returns {number} The index of the playing item. + */ + getCurrentPlaylistIndex() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.PlayingItemIndex; + } else { + return -1; + } + } + + /** + * Gets the playlist item id of the playing item. + * @returns {string} The playlist item id. + */ + getCurrentPlaylistItemId() { + if (this.lastPlayQueueUpdate) { + const index = this.lastPlayQueueUpdate.PlayingItemIndex; + return index === -1 ? null : this.playlist[index].PlaylistItemId; + } else { + return null; + } + } + + /** + * Gets a copy of the playlist. + * @returns {Array} The playlist. + */ + getPlaylist() { + return this.playlist.slice(0); + } + + /** + * Checks if playlist is empty. + * @returns {boolean} _true_ if playlist is empty, _false_ otherwise. + */ + isPlaylistEmpty() { + return this.playlist.length === 0; + } + + /** + * Gets the last update time as date, if any. + * @returns {Date} The date. + */ + getLastUpdate() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.LastUpdate; + } else { + return null; + } + } + + /** + * Gets the time of when the queue has been updated. + * @returns {number} The last update time. + */ + getLastUpdateTime() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.LastUpdate.getTime(); + } else { + return 0; + } + } + + /** + * Gets the last reported start position ticks of playing item. + * @returns {number} The start position ticks. + */ + getStartPositionTicks() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.StartPositionTicks; + } else { + return 0; + } + } + + /** + * Gets the list of item identifiers in the playlist. + * @returns {Array} The list of items. + */ + getPlaylistAsItemIds() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.Playlist.map(queueItem => queueItem.ItemId); + } else { + return []; + } + } + + /** + * Gets the repeat mode. + * @returns {string} The repeat mode. + */ + getRepeatMode() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.RepeatMode; + } else { + return 'Sorted'; + } + } + /** + * Gets the shuffle mode. + * @returns {string} The shuffle mode. + */ + getShuffleMode() { + if (this.lastPlayQueueUpdate) { + return this.lastPlayQueueUpdate.ShuffleMode; + } else { + return 'RepeatNone'; + } + } +} + +export default SyncPlayQueueCore; diff --git a/src/components/syncPlay/core/timeSync/core.js b/src/components/syncPlay/core/timeSync/core.js new file mode 100644 index 0000000000..0605202a15 --- /dev/null +++ b/src/components/syncPlay/core/timeSync/core.js @@ -0,0 +1,78 @@ +/** + * Module that manages time syncing with several devices. + * @module components/syncPlay/core/timeSync/core + */ + +import { Events } from 'jellyfin-apiclient'; +import TimeSyncServer from './server'; + +/** + * Class that manages time syncing with several devices. + */ +class TimeSyncCore { + constructor() { + this.manager = null; + this.timeSyncServer = null; + } + + /** + * Initializes the core. + * @param {SyncPlayManager} syncPlayManager The SyncPlay manager. + */ + init(syncPlayManager) { + this.manager = syncPlayManager; + this.timeSyncServer = new TimeSyncServer(syncPlayManager); + + Events.on(this.timeSyncServer, 'update', (event, error, timeOffset, ping) => { + if (error) { + console.debug('SyncPlay TimeSyncCore: time sync with server issue:', error); + return; + } + + Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]); + }); + } + + /** + * Forces time update with server. + */ + forceUpdate() { + this.timeSyncServer.forceUpdate(); + } + + /** + * Gets the display name of the selected device for time sync. + * @returns {string} The display name. + */ + getActiveDeviceName() { + return 'Server'; + } + + /** + * Converts server time to local time. + * @param {Date} remote The time to convert. + * @returns {Date} Local time. + */ + remoteDateToLocal(remote) { + return this.timeSyncServer.remoteDateToLocal(remote); + } + + /** + * Converts local time to server time. + * @param {Date} local The time to convert. + * @returns {Date} Server time. + */ + localDateToRemote(local) { + return this.timeSyncServer.localDateToRemote(local); + } + + /** + * Gets time offset that should be used for time syncing, in milliseconds. + * @returns {number} The time offset. + */ + getTimeOffset() { + return this.timeSyncServer.getTimeOffset(); + } +} + +export default TimeSyncCore; diff --git a/src/components/syncPlay/core/timeSync/server.js b/src/components/syncPlay/core/timeSync/server.js new file mode 100644 index 0000000000..e8aab0482c --- /dev/null +++ b/src/components/syncPlay/core/timeSync/server.js @@ -0,0 +1,39 @@ +/** + * Module that manages time syncing with server. + * @module components/syncPlay/core/timeSync/server + */ + +import TimeSync from './timeSync'; + +/** + * Class that manages time syncing with server. + */ +class TimeSyncServer extends TimeSync { + constructor(syncPlayManager) { + super(syncPlayManager); + } + + /** + * Makes a ping request to the server. + */ + requestPing() { + const apiClient = this.manager.getApiClient(); + const requestSent = new Date(); + let responseReceived; + return apiClient.getServerTime().then((response) => { + responseReceived = new Date(); + return response.json(); + }).then((data) => { + const requestReceived = new Date(data.RequestReceptionTime); + const responseSent = new Date(data.ResponseTransmissionTime); + return Promise.resolve({ + requestSent: requestSent, + requestReceived: requestReceived, + responseSent: responseSent, + responseReceived: responseReceived + }); + }); + } +} + +export default TimeSyncServer; diff --git a/src/components/syncPlay/timeSyncManager.js b/src/components/syncPlay/core/timeSync/timeSync.js similarity index 53% rename from src/components/syncPlay/timeSyncManager.js rename to src/components/syncPlay/core/timeSync/timeSync.js index 78c160824d..374e4eab00 100644 --- a/src/components/syncPlay/timeSyncManager.js +++ b/src/components/syncPlay/core/timeSync/timeSync.js @@ -1,13 +1,12 @@ /** - * Module that manages time syncing with server. - * @module components/syncPlay/timeSyncManager + * Module that manages time syncing with another device. + * @module components/syncPlay/core/timeSync/timeSync */ import { Events } from 'jellyfin-apiclient'; -import ServerConnections from '../ServerConnections'; /** - * Time estimation + * Time estimation. */ const NumberOfTrackedMeasurements = 8; const PollingIntervalGreedy = 1000; // milliseconds @@ -21,8 +20,8 @@ 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} requestReceived Remote's timestamp of the request reception + * @param {Date} responseSent Remote's timestamp of the response transmission * @param {Date} responseReceived Client's timestamp of the response reception */ constructor(requestSent, requestReceived, responseSent, responseReceived) { @@ -33,32 +32,33 @@ class Measurement { } /** - * Time offset from server. + * Time offset from remote entity, in milliseconds. */ - getOffset () { + getOffset() { return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2; } /** - * Get round-trip delay. + * Get round-trip delay, in milliseconds. */ - getDelay () { + getDelay() { return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived); } /** - * Get ping time. + * Get ping time, in milliseconds. */ - getPing () { + getPing() { return this.getDelay() / 2; } } /** - * Class that manages time syncing with server. + * Class that manages time syncing with remote entity. */ -class TimeSyncManager { - constructor() { +class TimeSync { + constructor(syncPlayManager) { + this.manager = syncPlayManager; this.pingStop = true; this.pollingInterval = PollingIntervalGreedy; this.poller = null; @@ -76,23 +76,23 @@ class TimeSyncManager { } /** - * Gets time offset with server. + * Gets time offset with remote entity, in milliseconds. * @returns {number} The time offset. */ - getTimeOffset () { + getTimeOffset() { return this.measurement ? this.measurement.getOffset() : 0; } /** - * Gets ping time to server. + * Gets ping time to remote entity, in milliseconds. * @returns {number} The ping time. */ - getPing () { + getPing() { return this.measurement ? this.measurement.getPing() : 0; } /** - * Updates time offset between server and client. + * Updates time offset between remote entity and local entity. * @param {Measurement} measurement The new measurement. */ updateTimeOffset(measurement) { @@ -101,53 +101,68 @@ class TimeSyncManager { this.measurements.shift(); } - // Pick measurement with minimum delay + // 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. + * Schedules a ping request to the remote entity. Triggers time offset update. + * @returns {Promise} Resolves on request success. */ requestPing() { - if (!this.poller) { + console.warn('SyncPlay TimeSync requestPing: override this method!'); + return Promise.reject('Not implemented.'); + } + + /** + * Poller for ping requests. + */ + internalRequestPing() { + if (!this.poller && !this.pingStop) { this.poller = setTimeout(() => { this.poller = null; - const apiClient = ServerConnections.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.requestPing() + .then((result) => this.onPingResponseCallback(result)) + .catch((error) => this.onPingRequestErrorCallback(error)) + .finally(() => this.internalRequestPing()); }, this.pollingInterval); } } + /** + * Handles a successful ping request. + * @param {Object} result The ping result. + */ + onPingResponseCallback(result) { + const { requestSent, requestReceived, responseSent, responseReceived } = result; + const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived); + this.updateTimeOffset(measurement); + + // Avoid overloading network. + if (this.pings >= GreedyPingCount) { + this.pollingInterval = PollingIntervalLowProfile; + } else { + this.pings++; + } + + Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]); + } + + /** + * Handles a failed ping request. + * @param {Object} error The error. + */ + onPingRequestErrorCallback(error) { + console.error(error); + Events.trigger(this, 'update', [error, null, null]); + } + /** * Drops accumulated measurements. */ - resetMeasurements () { + resetMeasurements() { this.measurement = null; this.measurements = []; } @@ -156,13 +171,15 @@ class TimeSyncManager { * Starts the time poller. */ startPing() { - this.requestPing(); + this.pingStop = false; + this.internalRequestPing(); } /** * Stops the time poller. */ stopPing() { + this.pingStop = true; if (this.poller) { clearTimeout(this.poller); this.poller = null; @@ -180,25 +197,24 @@ class TimeSyncManager { } /** - * Converts server time to local time. - * @param {Date} server The time to convert. + * Converts remote time to local time. + * @param {Date} remote The time to convert. * @returns {Date} Local time. */ - serverDateToLocal(server) { - // server - local = offset - return new Date(server.getTime() - this.getTimeOffset()); + remoteDateToLocal(remote) { + // remote - local = offset + return new Date(remote.getTime() - this.getTimeOffset()); } /** - * Converts local time to server time. + * Converts local time to remote time. * @param {Date} local The time to convert. - * @returns {Date} Server time. + * @returns {Date} Remote time. */ - localDateToServer(local) { - // server - local = offset + localDateToRemote(local) { + // remote - local = offset return new Date(local.getTime() + this.getTimeOffset()); } } -/** TimeSyncManager singleton. */ -export default new TimeSyncManager(); +export default TimeSync; diff --git a/src/components/syncPlay/groupSelectionMenu.js b/src/components/syncPlay/groupSelectionMenu.js deleted file mode 100644 index 5168558bb2..0000000000 --- a/src/components/syncPlay/groupSelectionMenu.js +++ /dev/null @@ -1,189 +0,0 @@ -import { Events } from 'jellyfin-apiclient'; -import { playbackManager } from '../playback/playbackmanager'; -import syncPlayManager from './syncPlayManager'; -import loading from '../loading/loading'; -import toast from '../toast/toast'; -import actionsheet from '../actionSheet/actionSheet'; -import globalize from '../../scripts/globalize'; -import playbackPermissionManager from './playbackPermissionManager'; -import ServerConnections from '../ServerConnections'; - -/** - * Gets active player id. - * @returns {string} The player's id. - */ -function getActivePlayerId () { - const 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.getSyncPlayGroups().then(function (response) { - response.json().then(function (groups) { - const 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; - } - - const menuOptions = { - title: globalize.translate('HeaderSyncPlaySelectGroup'), - items: menuItems, - positionTo: button, - resolveOnClick: true, - border: true - }; - - actionsheet.show(menuOptions).then(function (id) { - if (id == 'new-group') { - apiClient.createSyncPlayGroup(); - } else if (id) { - apiClient.joinSyncPlayGroup({ - 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') - }]; - - const menuOptions = { - title: globalize.translate('HeaderSyncPlayEnabled'), - items: menuItems, - positionTo: button, - resolveOnClick: true, - border: true - }; - - actionsheet.show(menuOptions).then(function (id) { - if (id == 'leave-group') { - apiClient.leaveSyncPlayGroup(); - } - }).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 = ServerConnections.currentApiClient(); - ServerConnections.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/syncPlayManager.js b/src/components/syncPlay/syncPlayManager.js deleted file mode 100644 index 2db5f3817b..0000000000 --- a/src/components/syncPlay/syncPlayManager.js +++ /dev/null @@ -1,838 +0,0 @@ -/** - * Module that manages the SyncPlay feature. - * @module components/syncPlay/syncPlayManager - */ - -import { Events } from 'jellyfin-apiclient'; -import { playbackManager } from '../playback/playbackmanager'; -import timeSyncManager from './timeSyncManager'; -import toast from '../toast/toast'; -import globalize from '../../scripts/globalize'; -import ServerConnections from '../ServerConnections'; - -/** - * 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() { - const 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 = ServerConnections.currentApiClient(); - const sessionId = getActivePlayerId(); - - if (!sessionId) { - this.signalError(); - toast({ - text: globalize.translate('MessageSyncPlayErrorMissingSession') - }); - return; - } - - apiClient.sendSyncPlayPing({ - 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); - - // Save player current PlaybackRate value - if (player.supports && player.supports('PlaybackRate')) { - this.localPlayerPlaybackRate = player.getPlaybackRate(); - } - } - - /** - * Removes the bindings to the current player's events. - */ - releaseCurrentPlayer () { - const 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(() => { - const 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.joinSyncPlayGroup({ - 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) { - const apiClient = ServerConnections.currentApiClient(); - apiClient.requestSyncPlayStart(); - } - - /** - * Overrides PlaybackManager's pause method. - */ - pauseRequest (player) { - const apiClient = ServerConnections.currentApiClient(); - apiClient.requestSyncPlayPause(); - // Pause locally as well, to give the user some little control - playbackManager._localUnpause(player); - } - - /** - * Overrides PlaybackManager's seek method. - */ - seekRequest (PositionTicks, player) { - const apiClient = ServerConnections.currentApiClient(); - apiClient.requestSyncPlaySeek({ - 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() * 10000; - // 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/ui/groupSelectionMenu.js b/src/components/syncPlay/ui/groupSelectionMenu.js new file mode 100644 index 0000000000..70b72da7e1 --- /dev/null +++ b/src/components/syncPlay/ui/groupSelectionMenu.js @@ -0,0 +1,189 @@ +import { Events } from 'jellyfin-apiclient'; +import SyncPlay from 'SyncPlay'; +import loading from '../../loading/loading'; +import toast from '../../toast/toast'; +import actionsheet from '../../actionSheet/actionSheet'; +import globalize from '../../../scripts/globalize'; +import playbackPermissionManager from './playbackPermissionManager'; +import ServerConnections from '../../ServerConnections'; + +/** + * Class that manages the SyncPlay group selection menu. + */ +class GroupSelectionMenu { + constructor() { + // Register to SyncPlay events. + this.syncPlayEnabled = false; + Events.on(SyncPlay.Manager, 'enabled', (e, enabled) => { + this.syncPlayEnabled = enabled; + }); + } + + /** + * 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. + */ + showNewJoinGroupSelection(button, user, apiClient) { + const policy = user.localUser ? user.localUser.Policy : {}; + + apiClient.getSyncPlayGroups().then(function (response) { + response.json().then(function (groups) { + const menuItems = groups.map(function (group) { + return { + name: group.GroupName, + icon: 'person', + id: group.GroupId, + selected: false, + secondaryText: group.Participants.join(', ') + }; + }); + + if (policy.SyncPlayAccess === 'CreateAndJoinGroups') { + menuItems.push({ + name: globalize.translate('LabelSyncPlayNewGroup'), + icon: 'add', + id: 'new-group', + selected: true, + secondaryText: globalize.translate('LabelSyncPlayNewGroupDescription') + }); + } + + if (menuItems.length === 0 && policy.SyncPlayAccess === 'JoinGroups') { + toast({ + text: globalize.translate('MessageSyncPlayCreateGroupDenied') + }); + loading.hide(); + return; + } + + const menuOptions = { + title: globalize.translate('HeaderSyncPlaySelectGroup'), + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == 'new-group') { + apiClient.createSyncPlayGroup({ + GroupName: globalize.translate('SyncPlayGroupDefaultTitle', user.localUser.Name) + }); + } else if (id) { + apiClient.joinSyncPlayGroup({ + GroupId: id + }); + } + }).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. + */ + showLeaveGroupSelection(button, user, apiClient) { + const groupInfo = SyncPlay.Manager.getGroupInfo(); + const menuItems = []; + + if (!SyncPlay.Manager.isPlaylistEmpty() && !SyncPlay.Manager.isPlaybackActive()) { + menuItems.push({ + name: globalize.translate('LabelSyncPlayResumePlayback'), + icon: 'play_circle_filled', + id: 'resume-playback', + selected: false, + secondaryText: globalize.translate('LabelSyncPlayResumePlaybackDescription') + }); + } else if (SyncPlay.Manager.isPlaybackActive()) { + menuItems.push({ + name: globalize.translate('LabelSyncPlayHaltPlayback'), + icon: 'pause_circle_filled', + id: 'halt-playback', + selected: false, + secondaryText: globalize.translate('LabelSyncPlayHaltPlaybackDescription') + }); + } + + menuItems.push({ + name: globalize.translate('LabelSyncPlayLeaveGroup'), + icon: 'meeting_room', + id: 'leave-group', + selected: true, + secondaryText: globalize.translate('LabelSyncPlayLeaveGroupDescription') + }); + + const menuOptions = { + title: groupInfo.GroupName, + items: menuItems, + positionTo: button, + resolveOnClick: true, + border: true + }; + + actionsheet.show(menuOptions).then(function (id) { + if (id == 'resume-playback') { + SyncPlay.Manager.resumeGroupPlayback(apiClient); + } else if (id == 'halt-playback') { + SyncPlay.Manager.haltGroupPlayback(apiClient); + } else if (id == 'leave-group') { + apiClient.leaveSyncPlayGroup(); + } + }).catch((error) => { + console.error('SyncPlay: unexpected error showing group menu:', error); + }); + + loading.hide(); + } + + /** + * Shows a menu to handle SyncPlay groups. + * @param {HTMLElement} button - Element where to place the menu. + */ + 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 = ServerConnections.currentApiClient(); + ServerConnections.user(apiClient).then((user) => { + if (this.syncPlayEnabled) { + this.showLeaveGroupSelection(button, user, apiClient); + } else { + this.showNewJoinGroupSelection(button, user, apiClient); + } + }).catch((error) => { + console.error(error); + loading.hide(); + toast({ + text: globalize.translate('MessageSyncPlayNoGroupsAvailable') + }); + }); + } +} + +/** GroupSelectionMenu singleton. */ +const groupSelectionMenu = new GroupSelectionMenu(); +export default groupSelectionMenu; diff --git a/src/components/syncPlay/playbackPermissionManager.js b/src/components/syncPlay/ui/playbackPermissionManager.js similarity index 100% rename from src/components/syncPlay/playbackPermissionManager.js rename to src/components/syncPlay/ui/playbackPermissionManager.js diff --git a/src/components/syncPlay/ui/players/htmlAudioPlayer.js b/src/components/syncPlay/ui/players/htmlAudioPlayer.js new file mode 100644 index 0000000000..5045f3b34d --- /dev/null +++ b/src/components/syncPlay/ui/players/htmlAudioPlayer.js @@ -0,0 +1,19 @@ +/** + * Module that manages the HtmlAudioPlayer for SyncPlay. + * @module components/syncPlay/players/htmlAudioPlayer + */ + +import SyncPlayHtmlVideoPlayer from './htmlVideoPlayer'; + +/** + * Class that manages the HtmlAudioPlayer for SyncPlay. + */ +class SyncPlayHtmlAudioPlayer extends SyncPlayHtmlVideoPlayer { + static type = 'htmlaudioplayer'; + + constructor(player, syncPlayManager) { + super(player, syncPlayManager); + } +} + +export default SyncPlayHtmlAudioPlayer; diff --git a/src/components/syncPlay/ui/players/htmlVideoPlayer.js b/src/components/syncPlay/ui/players/htmlVideoPlayer.js new file mode 100644 index 0000000000..aa09a97820 --- /dev/null +++ b/src/components/syncPlay/ui/players/htmlVideoPlayer.js @@ -0,0 +1,155 @@ +/** + * Module that manages the HtmlVideoPlayer for SyncPlay. + * @module components/syncPlay/players/htmlVideoPlayer + */ + +import { Events } from 'jellyfin-apiclient'; +import SyncPlayNoActivePlayer from './noActivePlayer'; + +/** + * Class that manages the HtmlVideoPlayer for SyncPlay. + */ +class SyncPlayHtmlVideoPlayer extends SyncPlayNoActivePlayer { + static type = 'htmlvideoplayer'; + + constructor(player, syncPlayManager) { + super(player, syncPlayManager); + this.isPlayerActive = false; + this.savedPlaybackRate = 1.0; + this.minBufferingThresholdMillis = 3000; + } + + /** + * Binds to the player's events. Overrides parent method. + * @param {Object} player The player. + */ + localBindToPlayer() { + super.localBindToPlayer(); + + const self = this; + + this._onPlaybackStart = (player, state) => { + self.isPlayerActive = true; + self.onPlaybackStart(player, state); + }; + + this._onPlaybackStop = (stopInfo) => { + self.isPlayerActive = false; + self.onPlaybackStop(stopInfo); + }; + + this._onUnpause = () => { + self.onUnpause(); + }; + + this._onPause = () => { + self.onPause(); + }; + + this._onTimeUpdate = (e) => { + const currentTime = new Date(); + const currentPosition = self.player.currentTime(); + self.onTimeUpdate(e, { + currentTime: currentTime, + currentPosition: currentPosition + }); + }; + + this._onPlaying = () => { + clearTimeout(self.notifyBuffering); + self.onReady(); + }; + + this._onWaiting = () => { + clearTimeout(self.notifyBuffering); + self.notifyBuffering = setTimeout(() => { + self.onBuffering(); + }, self.minBufferingThresholdMillis); + }; + + Events.on(this.player, 'playbackstart', this._onPlaybackStart); + Events.on(this.player, 'playbackstop', this._onPlaybackStop); + Events.on(this.player, 'unpause', this._onUnpause); + Events.on(this.player, 'pause', this._onPause); + Events.on(this.player, 'timeupdate', this._onTimeUpdate); + Events.on(this.player, 'playing', this._onPlaying); + Events.on(this.player, 'waiting', this._onWaiting); + + this.savedPlaybackRate = this.player.getPlaybackRate(); + } + + /** + * Removes the bindings from the player's events. Overrides parent method. + */ + localUnbindFromPlayer() { + super.localUnbindFromPlayer(); + + Events.off(this.player, 'playbackstart', this._onPlaybackStart); + Events.off(this.player, 'playbackstop', this._onPlaybackStop); + Events.off(this.player, 'unpause', this._onPlayerUnpause); + Events.off(this.player, 'pause', this._onPlayerPause); + Events.off(this.player, 'timeupdate', this._onTimeUpdate); + Events.off(this.player, 'playing', this._onPlaying); + Events.off(this.player, 'waiting', this._onWaiting); + + this.player.setPlaybackRate(this.savedPlaybackRate); + } + + /** + * Called when changes are made to the play queue. + */ + onQueueUpdate() { + // TODO: find a more generic event? Tests show that this is working for now. + Events.trigger(this.player, 'playlistitemadd'); + } + + /** + * Gets player status. + * @returns {boolean} Whether the player has some media loaded. + */ + isPlaybackActive() { + return this.isPlayerActive; + } + + /** + * Gets playback status. + * @returns {boolean} Whether the playback is unpaused. + */ + isPlaying() { + return !this.player.paused(); + } + + /** + * Gets playback position. + * @returns {number} The player position, in milliseconds. + */ + currentTime() { + return this.player.currentTime(); + } + + /** + * Checks if player has playback rate support. + * @returns {boolean} _true _ if playback rate is supported, false otherwise. + */ + hasPlaybackRate() { + return true; + } + + /** + * Sets the playback rate, if supported. + * @param {number} value The playback rate. + */ + setPlaybackRate(value) { + this.player.setPlaybackRate(value); + } + + /** + * Gets the playback rate. + * @returns {number} The playback rate. + */ + getPlaybackRate() { + return this.player.getPlaybackRate(); + } +} + +export default SyncPlayHtmlVideoPlayer; diff --git a/src/components/syncPlay/ui/players/noActivePlayer.js b/src/components/syncPlay/ui/players/noActivePlayer.js new file mode 100644 index 0000000000..5b541fc8f0 --- /dev/null +++ b/src/components/syncPlay/ui/players/noActivePlayer.js @@ -0,0 +1,444 @@ +/** + * Module that manages the PlaybackManager when there's no active player. + * @module components/syncPlay/players/genericPlayer + */ + +import { playbackManager } from '../../../playback/playbackmanager'; +import SyncPlay from 'SyncPlay'; +import QueueManager from './queueManager'; + +let syncPlayManager; + +/** + * Class that manages the PlaybackManager when there's no active player. + */ +class SyncPlayNoActivePlayer extends SyncPlay.Players.GenericPlayer { + static type = 'default'; + + constructor(player, _syncPlayManager) { + super(player, _syncPlayManager); + syncPlayManager = _syncPlayManager; + } + + /** + * Binds to the player's events. + */ + localBindToPlayer() { + if (playbackManager.syncPlayEnabled) return; + + // Save local callbacks. + playbackManager._localPlayPause = playbackManager.playPause; + playbackManager._localUnpause = playbackManager.unpause; + playbackManager._localPause = playbackManager.pause; + playbackManager._localSeek = playbackManager.seek; + playbackManager._localSendCommand = playbackManager.sendCommand; + + // Override local callbacks. + playbackManager.playPause = this.playPauseRequest; + playbackManager.unpause = this.unpauseRequest; + playbackManager.pause = this.pauseRequest; + playbackManager.seek = this.seekRequest; + playbackManager.sendCommand = this.sendCommandRequest; + + // Save local callbacks. + playbackManager._localPlayQueueManager = playbackManager._playQueueManager; + + playbackManager._localPlay = playbackManager.play; + playbackManager._localSetCurrentPlaylistItem = playbackManager.setCurrentPlaylistItem; + playbackManager._localRemoveFromPlaylist = playbackManager.removeFromPlaylist; + playbackManager._localMovePlaylistItem = playbackManager.movePlaylistItem; + playbackManager._localQueue = playbackManager.queue; + playbackManager._localQueueNext = playbackManager.queueNext; + + playbackManager._localNextTrack = playbackManager.nextTrack; + playbackManager._localPreviousTrack = playbackManager.previousTrack; + + playbackManager._localSetRepeatMode = playbackManager.setRepeatMode; + playbackManager._localSetQueueShuffleMode = playbackManager.setQueueShuffleMode; + playbackManager._localToggleQueueShuffleMode = playbackManager.toggleQueueShuffleMode; + + // Override local callbacks. + playbackManager._playQueueManager = new QueueManager(this.manager); + + playbackManager.play = this.playRequest; + playbackManager.setCurrentPlaylistItem = this.setCurrentPlaylistItemRequest; + playbackManager.removeFromPlaylist = this.removeFromPlaylistRequest; + playbackManager.movePlaylistItem = this.movePlaylistItemRequest; + playbackManager.queue = this.queueRequest; + playbackManager.queueNext = this.queueNextRequest; + + playbackManager.nextTrack = this.nextTrackRequest; + playbackManager.previousTrack = this.previousTrackRequest; + + playbackManager.setRepeatMode = this.setRepeatModeRequest; + playbackManager.setQueueShuffleMode = this.setQueueShuffleModeRequest; + playbackManager.toggleQueueShuffleMode = this.toggleQueueShuffleModeRequest; + + playbackManager.syncPlayEnabled = true; + } + + /** + * Removes the bindings from the player's events. + */ + localUnbindFromPlayer() { + if (!playbackManager.syncPlayEnabled) return; + + playbackManager.playPause = playbackManager._localPlayPause; + playbackManager.unpause = playbackManager._localUnpause; + playbackManager.pause = playbackManager._localPause; + playbackManager.seek = playbackManager._localSeek; + playbackManager.sendCommand = playbackManager._localSendCommand; + + playbackManager._playQueueManager = playbackManager._localPlayQueueManager; // TODO: should move elsewhere? + + playbackManager.play = playbackManager._localPlay; + playbackManager.setCurrentPlaylistItem = playbackManager._localSetCurrentPlaylistItem; + playbackManager.removeFromPlaylist = playbackManager._localRemoveFromPlaylist; + playbackManager.movePlaylistItem = playbackManager._localMovePlaylistItem; + playbackManager.queue = playbackManager._localQueue; + playbackManager.queueNext = playbackManager._localQueueNext; + + playbackManager.nextTrack = playbackManager._localNextTrack; + playbackManager.previousTrack = playbackManager._localPreviousTrack; + + playbackManager.setRepeatMode = playbackManager._localSetRepeatMode; + playbackManager.setQueueShuffleMode = playbackManager._localSetQueueShuffleMode; + playbackManager.toggleQueueShuffleMode = playbackManager._localToggleQueueShuffleMode; + + playbackManager.syncPlayEnabled = false; + } + + /** + * Overrides PlaybackManager's playPause method. + */ + playPauseRequest() { + const controller = syncPlayManager.getController(); + controller.playPause(); + } + + /** + * Overrides PlaybackManager's unpause method. + */ + unpauseRequest() { + const controller = syncPlayManager.getController(); + controller.unpause(); + } + + /** + * Overrides PlaybackManager's pause method. + */ + pauseRequest() { + const controller = syncPlayManager.getController(); + controller.pause(); + } + + /** + * Overrides PlaybackManager's seek method. + */ + seekRequest(positionTicks, player) { + const controller = syncPlayManager.getController(); + controller.seek(positionTicks); + } + + /** + * Overrides PlaybackManager's sendCommand method. + */ + sendCommandRequest(cmd, player) { + console.debug('SyncPlay sendCommand:', cmd.Name, cmd); + const controller = syncPlayManager.getController(); + const playerWrapper = syncPlayManager.getPlayerWrapper(); + + const defaultAction = (command, player) => { + playerWrapper.localSendCommand(command); + }; + + const ignoreCallback = (command, player) => { + // Do nothing. + }; + + const SetRepeatModeCallback = (command, player) => { + controller.setRepeatMode(command.Arguments.RepeatMode); + }; + + const SetShuffleQueueCallback = (command, player) => { + controller.setShuffleMode(command.Arguments.ShuffleMode); + }; + + // Commands to override. + const overrideCommands = { + PlaybackRate: ignoreCallback, + SetRepeatMode: SetRepeatModeCallback, + SetShuffleQueue: SetShuffleQueueCallback + }; + + // Handle command. + const commandHandler = overrideCommands[cmd.Name]; + if (typeof commandHandler === 'function') { + commandHandler(cmd, player); + } else { + defaultAction(cmd, player); + } + } + + /** + * Calls original PlaybackManager's unpause method. + */ + localUnpause() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localUnpause(this.player); + } else { + playbackManager.unpause(this.player); + } + } + + /** + * Calls original PlaybackManager's pause method. + */ + localPause() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localPause(this.player); + } else { + playbackManager.pause(this.player); + } + } + + /** + * Calls original PlaybackManager's seek method. + */ + localSeek(positionTicks) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSeek(positionTicks, this.player); + } else { + playbackManager.seek(positionTicks, this.player); + } + } + + /** + * Calls original PlaybackManager's stop method. + */ + localStop() { + playbackManager.stop(this.player); + } + + /** + * Calls original PlaybackManager's sendCommand method. + */ + localSendCommand(cmd) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSendCommand(cmd, this.player); + } else { + playbackManager.sendCommand(cmd, this.player); + } + } + + /** + * Overrides PlaybackManager's play method. + */ + playRequest(options) { + const controller = syncPlayManager.getController(); + controller.play(options); + } + + /** + * Overrides PlaybackManager's setCurrentPlaylistItem method. + */ + setCurrentPlaylistItemRequest(playlistItemId, player) { + const controller = syncPlayManager.getController(); + controller.setCurrentPlaylistItem(playlistItemId); + } + + /** + * Overrides PlaybackManager's removeFromPlaylist method. + */ + removeFromPlaylistRequest(playlistItemIds, player) { + const controller = syncPlayManager.getController(); + controller.removeFromPlaylist(playlistItemIds); + } + + /** + * Overrides PlaybackManager's movePlaylistItem method. + */ + movePlaylistItemRequest(playlistItemId, newIndex, player) { + const controller = syncPlayManager.getController(); + controller.movePlaylistItem(playlistItemId, newIndex); + } + + /** + * Overrides PlaybackManager's queue method. + */ + queueRequest(options, player) { + const controller = syncPlayManager.getController(); + controller.queue(options); + } + + /** + * Overrides PlaybackManager's queueNext method. + */ + queueNextRequest(options, player) { + const controller = syncPlayManager.getController(); + controller.queueNext(options); + } + + /** + * Overrides PlaybackManager's nextTrack method. + */ + nextTrackRequest(player) { + const controller = syncPlayManager.getController(); + controller.nextTrack(); + } + + /** + * Overrides PlaybackManager's previousTrack method. + */ + previousTrackRequest(player) { + const controller = syncPlayManager.getController(); + controller.previousTrack(); + } + + /** + * Overrides PlaybackManager's setRepeatMode method. + */ + setRepeatModeRequest(mode, player) { + const controller = syncPlayManager.getController(); + controller.setRepeatMode(mode); + } + + /** + * Overrides PlaybackManager's setQueueShuffleMode method. + */ + setQueueShuffleModeRequest(mode, player) { + const controller = syncPlayManager.getController(); + controller.setShuffleMode(mode); + } + + /** + * Overrides PlaybackManager's toggleQueueShuffleMode method. + */ + toggleQueueShuffleModeRequest(player) { + const controller = syncPlayManager.getController(); + controller.toggleShuffleMode(); + } + + /** + * Calls original PlaybackManager's play method. + */ + localPlay(options) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localPlay(options); + } else { + return playbackManager.play(options); + } + } + + /** + * Calls original PlaybackManager's setCurrentPlaylistItem method. + */ + localSetCurrentPlaylistItem(playlistItemId) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localSetCurrentPlaylistItem(playlistItemId, this.player); + } else { + return playbackManager.setCurrentPlaylistItem(playlistItemId, this.player); + } + } + + /** + * Calls original PlaybackManager's removeFromPlaylist method. + */ + localRemoveFromPlaylist(playlistItemIds) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localRemoveFromPlaylist(playlistItemIds, this.player); + } else { + return playbackManager.removeFromPlaylist(playlistItemIds, this.player); + } + } + + /** + * Calls original PlaybackManager's movePlaylistItem method. + */ + localMovePlaylistItem(playlistItemId, newIndex) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localMovePlaylistItem(playlistItemId, newIndex, this.player); + } else { + return playbackManager.movePlaylistItem(playlistItemId, newIndex, this.player); + } + } + + /** + * Calls original PlaybackManager's queue method. + */ + localQueue(options) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localQueue(options, this.player); + } else { + return playbackManager.queue(options, this.player); + } + } + + /** + * Calls original PlaybackManager's queueNext method. + */ + localQueueNext(options) { + if (playbackManager.syncPlayEnabled) { + return playbackManager._localQueueNext(options, this.player); + } else { + return playbackManager.queueNext(options, this.player); + } + } + + /** + * Calls original PlaybackManager's nextTrack method. + */ + localNextTrack() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localNextTrack(this.player); + } else { + playbackManager.nextTrack(this.player); + } + } + + /** + * Calls original PlaybackManager's previousTrack method. + */ + localPreviousTrack() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localPreviousTrack(this.player); + } else { + playbackManager.previousTrack(this.player); + } + } + + /** + * Calls original PlaybackManager's setRepeatMode method. + */ + localSetRepeatMode(value) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSetRepeatMode(value, this.player); + } else { + playbackManager.setRepeatMode(value, this.player); + } + } + + /** + * Calls original PlaybackManager's setQueueShuffleMode method. + */ + localSetQueueShuffleMode(value) { + if (playbackManager.syncPlayEnabled) { + playbackManager._localSetQueueShuffleMode(value, this.player); + } else { + playbackManager.setQueueShuffleMode(value, this.player); + } + } + + /** + * Calls original PlaybackManager's toggleQueueShuffleMode method. + */ + localToggleQueueShuffleMode() { + if (playbackManager.syncPlayEnabled) { + playbackManager._localToggleQueueShuffleMode(this.player); + } else { + playbackManager.toggleQueueShuffleMode(this.player); + } + } +} + +export default SyncPlayNoActivePlayer; diff --git a/src/components/syncPlay/ui/players/queueManager.js b/src/components/syncPlay/ui/players/queueManager.js new file mode 100644 index 0000000000..aefd924d94 --- /dev/null +++ b/src/components/syncPlay/ui/players/queueManager.js @@ -0,0 +1,202 @@ +/** + * Module that replaces the PlaybackManager's queue. + * @module components/syncPlay/players/queueManager + */ + +/** + * Class that replaces the PlaybackManager's queue. + */ +class QueueManager { + constructor(syncPlayManager) { + this.queueCore = syncPlayManager.getQueueCore(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getPlaylist() { + return this.queueCore.getPlaylist(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setPlaylist(items) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + queue(items) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + shufflePlaylist() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + sortShuffledPlaylist() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + clearPlaylist(clearCurrentItem = false) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + queueNext(items) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getCurrentPlaylistIndex() { + return this.queueCore.getCurrentPlaylistIndex(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getCurrentItem() { + const index = this.getCurrentPlaylistIndex(); + if (index >= 0) { + const playlist = this.getPlaylist(); + return playlist[index]; + } else { + return null; + } + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getCurrentPlaylistItemId() { + return this.queueCore.getCurrentPlaylistItemId(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setPlaylistState(playlistItemId, playlistIndex) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setPlaylistIndex(playlistIndex) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + removeFromPlaylist(playlistItemIds) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + movePlaylistItem(playlistItemId, newIndex) { + // Do nothing. + return { + result: 'noop' + }; + } + + /** + * Placeholder for original PlayQueueManager method. + */ + reset() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setRepeatMode(value) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getRepeatMode() { + return this.queueCore.getRepeatMode(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + setShuffleMode(value) { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + toggleShuffleMode() { + // Do nothing. + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getShuffleMode() { + return this.queueCore.getShuffleMode(); + } + + /** + * Placeholder for original PlayQueueManager method. + */ + getNextItemInfo() { + const playlist = this.getPlaylist(); + let newIndex; + + switch (this.getRepeatMode()) { + case 'RepeatOne': + newIndex = this.getCurrentPlaylistIndex(); + break; + case 'RepeatAll': + newIndex = this.getCurrentPlaylistIndex() + 1; + if (newIndex >= playlist.length) { + newIndex = 0; + } + break; + default: + newIndex = this.getCurrentPlaylistIndex() + 1; + break; + } + + if (newIndex < 0 || newIndex >= playlist.length) { + return null; + } + + const item = playlist[newIndex]; + + if (!item) { + return null; + } + + return { + item: item, + index: newIndex + }; + } +} + +export default QueueManager; diff --git a/src/components/syncPlay/ui/syncPlayToasts.js b/src/components/syncPlay/ui/syncPlayToasts.js new file mode 100644 index 0000000000..31cfb25197 --- /dev/null +++ b/src/components/syncPlay/ui/syncPlayToasts.js @@ -0,0 +1,34 @@ +/** + * Module that notifies user about SyncPlay messages using toasts. + * @module components/syncPlay/syncPlayToasts + */ + +import { Events } from 'jellyfin-apiclient'; +import toast from '../../toast/toast'; +import globalize from '../../../scripts/globalize'; +import SyncPlay from 'SyncPlay'; + +/** + * Class that notifies user about SyncPlay messages using toasts. + */ +class SyncPlayToasts { + constructor() { + // Do nothing. + } + + /** + * Listens for messages to show. + */ + init() { + Events.on(SyncPlay.Manager, 'show-message', (event, data) => { + const { message, args = [] } = data; + toast({ + text: globalize.translate(message, ...args) + }); + }); + } +} + +/** SyncPlayToasts singleton. */ +const syncPlayToasts = new SyncPlayToasts(); +export default syncPlayToasts; diff --git a/src/controllers/playback/video/index.html b/src/controllers/playback/video/index.html index 26407cf95e..c702154b21 100644 --- a/src/controllers/playback/video/index.html +++ b/src/controllers/playback/video/index.html @@ -1,4 +1,10 @@
+
+
+ + +
+
diff --git a/src/controllers/playback/video/index.js b/src/controllers/playback/video/index.js index 0cc37fa985..d440d88da5 100644 --- a/src/controllers/playback/video/index.js +++ b/src/controllers/playback/video/index.js @@ -1,4 +1,5 @@ import { playbackManager } from '../../../components/playback/playbackmanager'; +import SyncPlay from 'SyncPlay'; import dom from '../../../scripts/dom'; import inputManager from '../../../scripts/inputManager'; import mouseManager from '../../../scripts/mouseManager'; @@ -1568,6 +1569,111 @@ import { appRouter } from '../../../components/appRouter'; }); })(); } + + // Register to SyncPlay playback events and show big animated icon + const showIcon = (action) => { + let primary_icon_name = ''; + let secondary_icon_name = ''; + let animation_class = 'oneShotPulse'; + let iconVisibilityTime = 1500; + const syncPlayIcon = view.querySelector('#syncPlayIcon'); + + switch (action) { + case 'schedule-play': + primary_icon_name = 'sync spin'; + secondary_icon_name = 'play_arrow centered'; + animation_class = 'infinitePulse'; + iconVisibilityTime = -1; + hideOsd(); + break; + case 'unpause': + primary_icon_name = 'play_circle_outline'; + break; + case 'pause': + primary_icon_name = 'pause_circle_outline'; + showOsd(); + break; + case 'seek': + primary_icon_name = 'update'; + animation_class = 'infinitePulse'; + iconVisibilityTime = -1; + break; + case 'buffering': + primary_icon_name = 'schedule'; + animation_class = 'infinitePulse'; + iconVisibilityTime = -1; + break; + case 'wait-pause': + primary_icon_name = 'schedule'; + secondary_icon_name = 'pause shifted'; + animation_class = 'infinitePulse'; + iconVisibilityTime = -1; + break; + case 'wait-unpause': + primary_icon_name = 'schedule'; + secondary_icon_name = 'play_arrow shifted'; + animation_class = 'infinitePulse'; + iconVisibilityTime = -1; + break; + default: { + syncPlayIcon.style.visibility = 'hidden'; + return; + } + } + + syncPlayIcon.setAttribute('class', 'syncPlayIconCircle ' + animation_class); + + const primaryIcon = syncPlayIcon.querySelector('.primary-icon'); + primaryIcon.setAttribute('class', 'primary-icon material-icons ' + primary_icon_name); + + const secondaryIcon = syncPlayIcon.querySelector('.secondary-icon'); + secondaryIcon.setAttribute('class', 'secondary-icon material-icons ' + secondary_icon_name); + + const clone = syncPlayIcon.cloneNode(true); + clone.style.visibility = 'visible'; + syncPlayIcon.parentNode.replaceChild(clone, syncPlayIcon); + + if (iconVisibilityTime < 0) { + return; + } + + setTimeout(() => { + clone.style.visibility = 'hidden'; + }, iconVisibilityTime); + }; + + Events.on(SyncPlay.Manager, 'enabled', (event, enabled) => { + if (enabled) { + // SyncPlay enabled + } else { + const syncPlayIcon = view.querySelector('#syncPlayIcon'); + syncPlayIcon.style.visibility = 'hidden'; + } + }); + + Events.on(SyncPlay.Manager, 'notify-osd', (event, action) => { + showIcon(action); + }); + + Events.on(SyncPlay.Manager, 'group-state-update', (event, state, reason) => { + if (state === 'Playing' && reason === 'Unpause') { + showIcon('schedule-play'); + } else if (state === 'Playing' && reason === 'Ready') { + showIcon('schedule-play'); + } else if (state === 'Paused' && reason === 'Pause') { + showIcon('pause'); + } else if (state === 'Paused' && reason === 'Ready') { + showIcon('clear'); + } else if (state === 'Waiting' && reason === 'Seek') { + showIcon('seek'); + } else if (state === 'Waiting' && reason === 'Buffer') { + showIcon('buffering'); + } else if (state === 'Waiting' && reason === 'Pause') { + showIcon('wait-pause'); + } else if (state === 'Waiting' && reason === 'Unpause') { + showIcon('wait-unpause'); + } + }); } /* eslint-enable indent */ diff --git a/src/elements/emby-checkbox/emby-checkbox.css b/src/elements/emby-checkbox/emby-checkbox.css index b33a216140..e839e44f61 100644 --- a/src/elements/emby-checkbox/emby-checkbox.css +++ b/src/elements/emby-checkbox/emby-checkbox.css @@ -22,6 +22,10 @@ display: flex; } +.checkboxContainer-noText { + margin-bottom: 0; +} + .checkboxListContainer { margin-bottom: 1.8em; } @@ -63,6 +67,10 @@ justify-content: center; } +.checkboxContainer-noText .checkboxOutline { + top: auto; +} + .checkboxIcon { font-size: 1.6em; color: #fff; @@ -73,16 +81,19 @@ display: none; } +.emby-checkbox:checked + .checkboxOutline > .checkboxIcon-checked, .emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked { /* background color set by theme */ display: flex !important; } +.emby-checkbox:checked + .checkboxOutline > .checkboxIcon-unchecked, .emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-unchecked { /* background color set by theme */ display: none !important; } +.emby-checkbox:checked[disabled] + .checkboxOutline > .checkboxIcon, .emby-checkbox:checked[disabled] + span + .checkboxOutline > .checkboxIcon { background-color: rgba(0, 0, 0, 0.26); } diff --git a/src/elements/emby-checkbox/emby-checkbox.js b/src/elements/emby-checkbox/emby-checkbox.js index 0af079ec06..6976d1df8a 100644 --- a/src/elements/emby-checkbox/emby-checkbox.js +++ b/src/elements/emby-checkbox/emby-checkbox.js @@ -65,7 +65,9 @@ import 'webcomponents.js/webcomponents-lite'; const uncheckedHtml = ''; labelElement.insertAdjacentHTML('beforeend', '' + checkHtml + uncheckedHtml + ''); - labelTextElement.classList.add('checkboxLabel'); + if (labelTextElement) { + labelTextElement.classList.add('checkboxLabel'); + } this.addEventListener('keydown', onKeyDown); diff --git a/src/scripts/libraryMenu.js b/src/scripts/libraryMenu.js index 5c72685749..40d1242015 100644 --- a/src/scripts/libraryMenu.js +++ b/src/scripts/libraryMenu.js @@ -6,8 +6,8 @@ import viewManager from '../components/viewManager/viewManager'; import { appRouter } from '../components/appRouter'; import { appHost } from '../components/apphost'; import { playbackManager } from '../components/playback/playbackmanager'; -import syncPlayManager from '../components/syncPlay/syncPlayManager'; -import { show as groupSelectionMenuShow } from '../components/syncPlay/groupSelectionMenu'; +import SyncPlay from 'SyncPlay'; +import groupSelectionMenu from '../components/syncPlay/ui/groupSelectionMenu'; import browser from './browser'; import globalize from './globalize'; import imageHelper from './imagehelper'; @@ -230,7 +230,7 @@ import Headroom from 'headroom.js'; function onSyncButtonClicked() { const btn = this; - groupSelectionMenuShow(btn); + groupSelectionMenu.show(btn); } function onSyncPlayEnabled(event, enabled) { @@ -1000,8 +1000,8 @@ import Headroom from 'headroom.js'; Events.on(playbackManager, 'playerchange', updateCastIcon); - Events.on(syncPlayManager, 'enabled', onSyncPlayEnabled); - Events.on(syncPlayManager, 'syncing', onSyncPlaySyncing); + Events.on(SyncPlay.Manager, 'enabled', onSyncPlayEnabled); + Events.on(SyncPlay.Manager, 'syncing', onSyncPlaySyncing); loadNavDrawer(); diff --git a/src/scripts/serverNotifications.js b/src/scripts/serverNotifications.js index 1ea03ea343..22a62d4417 100644 --- a/src/scripts/serverNotifications.js +++ b/src/scripts/serverNotifications.js @@ -1,5 +1,5 @@ import { playbackManager } from '../components/playback/playbackmanager'; -import syncPlayManager from '../components/syncPlay/syncPlayManager'; +import SyncPlay from 'SyncPlay'; import { Events } from 'jellyfin-apiclient'; import inputManager from '../scripts/inputManager'; import focusManager from '../components/focusManager'; @@ -194,9 +194,9 @@ function onMessageReceived(e, msg) { } } } else if (msg.MessageType === 'SyncPlayCommand') { - syncPlayManager.processCommand(msg.Data, apiClient); + SyncPlay.Manager.processCommand(msg.Data, apiClient); } else if (msg.MessageType === 'SyncPlayGroupUpdate') { - syncPlayManager.processGroupUpdate(msg.Data, apiClient); + SyncPlay.Manager.processGroupUpdate(msg.Data, apiClient); } else { Events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]); } diff --git a/src/scripts/site.js b/src/scripts/site.js index dba2f83bad..8f99cce251 100644 --- a/src/scripts/site.js +++ b/src/scripts/site.js @@ -30,6 +30,12 @@ import { navigate, pageClassOn, serverAddress } from './clientUtils'; import '../libraries/screensavermanager'; import './serverNotifications'; import '../components/playback/playerSelectionMenu'; +import SyncPlay from 'SyncPlay'; +import { playbackManager } from '../components/playback/playbackmanager'; +import SyncPlayToasts from '../components/syncPlay/ui/syncPlayToasts'; +import SyncPlayNoActivePlayer from '../components/syncPlay/ui/players/noActivePlayer'; +import SyncPlayHtmlVideoPlayer from '../components/syncPlay/ui/players/htmlVideoPlayer'; +import SyncPlayHtmlAudioPlayer from '../components/syncPlay/ui/players/htmlAudioPlayer'; // TODO: Move this elsewhere window.getWindowLocationSearch = function(win) { @@ -117,6 +123,7 @@ function onGlobalizeInit() { import('../assets/css/librarybrowser.css'); loadPlugins().then(function () { + initSyncPlay(); onAppReady(); }); } @@ -154,6 +161,23 @@ function loadPlugins() { }); } +function initSyncPlay() { + // Register player wrappers. + SyncPlay.PlayerFactory.setDefaultWrapper(SyncPlayNoActivePlayer); + SyncPlay.PlayerFactory.registerWrapper(SyncPlayHtmlVideoPlayer); + SyncPlay.PlayerFactory.registerWrapper(SyncPlayHtmlAudioPlayer); + + // Listen for player changes. + Events.on(playbackManager, 'playerchange', (event, newPlayer, newTarget, oldPlayer) => { + SyncPlay.Manager.onPlayerChange(newPlayer, newTarget, oldPlayer); + }); + + // Start SyncPlay. + const apiClient = ServerConnections.currentApiClient(); + SyncPlay.Manager.init(apiClient); + SyncPlayToasts.init(); +} + function onAppReady() { console.debug('begin onAppReady'); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index efdc2141aa..714c019551 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -162,7 +162,7 @@ "Delete": "Delete", "DeleteAll": "Delete All", "DeleteDeviceConfirmation": "Are you sure you wish to delete this device? It will reappear the next time a user signs in with it.", - "DeleteDevicesConfirmation": "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.", + "DeleteDevicesConfirmation": "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.", "DeleteImage": "Delete Image", "DeleteImageConfirmation": "Are you sure you wish to delete this image?", "DeleteMedia": "Delete media", @@ -842,13 +842,18 @@ "LabelSyncPlayAccessCreateAndJoinGroups": "Allow user to create and join groups", "LabelSyncPlayAccessJoinGroups": "Allow user to join groups", "LabelSyncPlayAccessNone": "Disabled for this user", + "LabelSyncPlayHaltPlayback": "Stop local playback", + "LabelSyncPlayHaltPlaybackDescription": "And ignore current playlist updates", "LabelSyncPlayLeaveGroup": "Leave group", "LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay", "LabelSyncPlayNewGroup": "New group", "LabelSyncPlayNewGroupDescription": "Create a new group", "LabelSyncPlayPlaybackDiff": "Playback time difference:", + "LabelSyncPlayResumePlayback": "Resume local playback", + "LabelSyncPlayResumePlaybackDescription": "Join back group playback", "LabelSyncPlaySyncMethod": "Sync method:", - "LabelSyncPlayTimeOffset": "Time offset with the server:", + "LabelSyncPlayTimeSyncDevice": "Time syncing with:", + "LabelSyncPlayTimeSyncOffset": "Time offset:", "LabelTag": "Tag:", "LabelTagline": "Tagline:", "LabelTextBackgroundColor": "Text background color:", @@ -1023,7 +1028,8 @@ "MessageSyncPlayErrorNoActivePlayer": "No active player found. SyncPlay has been disabled.", "MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist.", "MessageSyncPlayGroupWait": "{0} is buffering…", - "MessageSyncPlayJoinGroupDenied": "Permission required to use SyncPlay.", + "MessageSyncPlayIsDisabled": "Permission required to use SyncPlay.", + "MessageSyncPlayJoinGroupDenied": "Cannot join group.", "MessageSyncPlayLibraryAccessDenied": "Access to this content is restricted.", "MessageSyncPlayNoGroupsAvailable": "No groups available. Start playing something first.", "MessageSyncPlayPlaybackPermissionRequired": "Playback permission required.", @@ -1352,6 +1358,7 @@ "Sunday": "Sunday", "Sync": "Sync", "SyncPlayAccessHelp": "Select the level of access this user has to the SyncPlay feature. SyncPlay enables to sync playback with other devices.", + "SyncPlayGroupDefaultTitle": "{0}'s group", "SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.", "TabAccess": "Access", "TabAdvanced": "Advanced", diff --git a/src/themes/appletv/theme.css b/src/themes/appletv/theme.css index 535e18ff99..a4fd330307 100644 --- a/src/themes/appletv/theme.css +++ b/src/themes/appletv/theme.css @@ -294,15 +294,18 @@ html { border: 0.07em solid rgba(0, 0, 0, 0.158); } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .emby-select-withcolor:focus { border-color: #00a4dc; } +.emby-checkbox:focus + .checkboxOutline, .emby-checkbox:focus + span + .checkboxOutline { border-color: #fff; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline { background-color: #00a4dc; } @@ -321,6 +324,7 @@ html { margin: 0.4rem 0.5rem 0.4rem 0.5rem; } +.emby-checkbox:focus:not(:checked) + .checkboxOutline, .emby-checkbox:focus:not(:checked) + span + .checkboxOutline { border-color: #00a4dc; } diff --git a/src/themes/blueradiance/theme.css b/src/themes/blueradiance/theme.css index f8ef781beb..5bcb48ad5b 100644 --- a/src/themes/blueradiance/theme.css +++ b/src/themes/blueradiance/theme.css @@ -308,19 +308,23 @@ html { color: #fff !important; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline { border-color: #00a4dc; } +.emby-checkbox:focus + .checkboxOutline, .emby-checkbox:focus + span + .checkboxOutline { border-color: #fff; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .itemProgressBarForeground { background-color: #00a4dc; } +.emby-checkbox:focus:not(:checked) + .checkboxOutline, .emby-checkbox:focus:not(:checked) + span + .checkboxOutline { border-color: #00a4dc; } diff --git a/src/themes/dark/theme.css b/src/themes/dark/theme.css index d1f0ead9e7..9236b0e148 100644 --- a/src/themes/dark/theme.css +++ b/src/themes/dark/theme.css @@ -289,19 +289,23 @@ html { color: #fff !important; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline { border-color: #00a4dc; } +.emby-checkbox:focus + .checkboxOutline, .emby-checkbox:focus + span + .checkboxOutline { border-color: #fff; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .itemProgressBarForeground { background-color: #00a4dc; } +.emby-checkbox:focus:not(:checked) + .checkboxOutline, .emby-checkbox:focus:not(:checked) + span + .checkboxOutline { border-color: #00a4dc; } diff --git a/src/themes/light/theme.css b/src/themes/light/theme.css index a241c80567..1d299d5e8b 100644 --- a/src/themes/light/theme.css +++ b/src/themes/light/theme.css @@ -293,20 +293,24 @@ html { border: 0.07em solid rgba(0, 0, 0, 0.158); } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .emby-select-withcolor:focus { border-color: #00a4dc; } +.emby-checkbox:focus + .checkboxOutline, .emby-checkbox:focus + span + .checkboxOutline { border-color: #000; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .itemProgressBarForeground { background-color: #00a4dc; } +.emby-checkbox:focus:not(:checked) + .checkboxOutline, .emby-checkbox:focus:not(:checked) + span + .checkboxOutline { border-color: #00a4dc; } diff --git a/src/themes/purplehaze/theme.css b/src/themes/purplehaze/theme.css index ad85d39583..616ada26da 100644 --- a/src/themes/purplehaze/theme.css +++ b/src/themes/purplehaze/theme.css @@ -105,11 +105,13 @@ progress::-webkit-progress-value { background: #ff77f1; } +div[data-role=controlgroup] .controlGroupButton.ui-btn-active, div[data-role=controlgroup] a.ui-btn-active { background: #55828b !important; color: #e1e5f2 !important; } +.controlGroupButton, a[data-role=button] { background: rgba(2, 43, 58, 0.521) !important; } @@ -396,19 +398,23 @@ a[data-role=button] { color: #fff !important; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline { background-color: #030322; border: 0.14em solid rgb(72, 195, 200); } +.emby-checkbox:checked + .checkboxOutline > .minimalCheckboxIcon-checked, .emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked { color: rgb(12, 232, 214); } +.emby-checkbox:focus + .checkboxOutline, .emby-checkbox:focus + span + .checkboxOutline { border-color: #ff77f1; } +.emby-checkbox:focus:not(:checked) + .checkboxOutline, .emby-checkbox:focus:not(:checked) + span + .checkboxOutline { border: 0.14em solid #ff77f1; } diff --git a/src/themes/wmc/theme.css b/src/themes/wmc/theme.css index 3367821be1..371f8efd7b 100644 --- a/src/themes/wmc/theme.css +++ b/src/themes/wmc/theme.css @@ -276,20 +276,24 @@ html { border: 0.07em solid rgba(255, 255, 255, 0.135); } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .emby-select-withcolor:focus { border-color: #00a4dc; } +.emby-checkbox:focus + .checkboxOutline, .emby-checkbox:focus + span + .checkboxOutline { border-color: #fff; } +.emby-checkbox:checked + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline, .itemProgressBarForeground { background-color: #00a4dc; } +.emby-checkbox:focus:not(:checked) + .checkboxOutline, .emby-checkbox:focus:not(:checked) + span + .checkboxOutline { border-color: #00a4dc; } diff --git a/webpack.common.js b/webpack.common.js index c9be6c7b98..ad63add1d1 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -29,7 +29,10 @@ module.exports = { resolve: { modules: [ path.resolve(__dirname, 'node_modules') - ] + ], + alias: { + 'SyncPlay': path.resolve(__dirname, 'src/components/syncPlay/core') + } }, plugins: [ new CleanWebpackPlugin(),