mirror of
https://github.com/jellyfin/jellyfin-web.git
synced 2024-11-17 19:08:18 -07:00
Add playlist-sync and group-wait to SyncPlay
This commit is contained in:
parent
46a0382c0a
commit
d8beb9909f
@ -77,6 +77,7 @@ progress[aria-valuenow]::before {
|
|||||||
height: 4em;
|
height: 4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controlGroupButton,
|
||||||
a[data-role=button] {
|
a[data-role=button] {
|
||||||
background: #292929 !important;
|
background: #292929 !important;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
@ -93,6 +94,7 @@ a[data-role=button] {
|
|||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div[data-role=controlgroup] .controlGroupButton,
|
||||||
div[data-role=controlgroup] a[data-role=button] {
|
div[data-role=controlgroup] a[data-role=button] {
|
||||||
display: inline-block !important;
|
display: inline-block !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@ -102,6 +104,7 @@ div[data-role=controlgroup] a[data-role=button] {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div[data-role=controlgroup] .controlGroupButton:first-child,
|
||||||
div[data-role=controlgroup] a[data-role=button]:first-child {
|
div[data-role=controlgroup] a[data-role=button]:first-child {
|
||||||
-webkit-border-bottom-left-radius: 0.3125em;
|
-webkit-border-bottom-left-radius: 0.3125em;
|
||||||
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;
|
border-top-left-radius: 0.3125em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div[data-role=controlgroup] .controlGroupButton:last-child,
|
||||||
div[data-role=controlgroup] a[data-role=button]:last-child {
|
div[data-role=controlgroup] a[data-role=button]:last-child {
|
||||||
-webkit-border-bottom-right-radius: 0.3125em;
|
-webkit-border-bottom-right-radius: 0.3125em;
|
||||||
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;
|
border-top-right-radius: 0.3125em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div[data-role=controlgroup] .controlGroupButton + .controlGroupButton,
|
||||||
div[data-role=controlgroup] a[data-role=button] + a[data-role=button] {
|
div[data-role=controlgroup] a[data-role=button] + a[data-role=button] {
|
||||||
border-left-width: 0 !important;
|
border-left-width: 0 !important;
|
||||||
margin: 0 0 0 -0.4em !important;
|
margin: 0 0 0 -0.4em !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div[data-role=controlgroup] .controlGroupButton.ui-btn-active,
|
||||||
div[data-role=controlgroup] a.ui-btn-active {
|
div[data-role=controlgroup] a.ui-btn-active {
|
||||||
background: #00a4dc !important;
|
background: #00a4dc !important;
|
||||||
color: #292929 !important;
|
color: #292929 !important;
|
||||||
|
@ -255,3 +255,118 @@
|
|||||||
display: none !important;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1876,6 +1876,9 @@ class PlaybackManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.translateItemsForPlayback = translateItemsForPlayback;
|
||||||
|
self.getItemsForPlayback = getItemsForPlayback;
|
||||||
|
|
||||||
self.play = function (options) {
|
self.play = function (options) {
|
||||||
normalizePlayOptions(options);
|
normalizePlayOptions(options);
|
||||||
|
|
||||||
@ -2504,29 +2507,38 @@ class PlaybackManager {
|
|||||||
})[0];
|
})[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) {
|
self.setCurrentPlaylistItem = function (playlistItemId, player) {
|
||||||
player = player || self._currentPlayer;
|
player = player || self._currentPlayer;
|
||||||
if (player && !enableLocalPlaylistManagement(player)) {
|
if (player && !enableLocalPlaylistManagement(player)) {
|
||||||
return player.setCurrentPlaylistItem(playlistItemId);
|
return player.setCurrentPlaylistItem(playlistItemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let newItem;
|
const newItem = self.getItemFromPlaylistItemId(playlistItemId);
|
||||||
let newItemIndex;
|
|
||||||
const playlist = self._playQueueManager.getPlaylist();
|
|
||||||
|
|
||||||
for (let i = 0, length = playlist.length; i < length; i++) {
|
if (newItem.Item) {
|
||||||
if (playlist[i].PlaylistItemId === playlistItemId) {
|
const newItemPlayOptions = newItem.Item.playOptions || getDefaultPlayOptions();
|
||||||
newItem = playlist[i];
|
|
||||||
newItemIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newItem) {
|
playInternal(newItem.Item, newItemPlayOptions, function () {
|
||||||
const newItemPlayOptions = newItem.playOptions || getDefaultPlayOptions();
|
setPlaylistState(newItem.Item.PlaylistItemId, newItem.Index);
|
||||||
|
|
||||||
playInternal(newItem, newItemPlayOptions, function () {
|
|
||||||
setPlaylistState(newItem.PlaylistItemId, newItemIndex);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -2905,6 +2917,8 @@ class PlaybackManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Events.trigger(self, 'playbackerror', [errorType]);
|
||||||
|
|
||||||
const displayErrorCode = 'NoCompatibleStream';
|
const displayErrorCode = 'NoCompatibleStream';
|
||||||
onPlaybackStopped.call(player, e, displayErrorCode);
|
onPlaybackStopped.call(player, e, displayErrorCode);
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import globalize from '../../scripts/globalize';
|
|||||||
import layoutManager from '../layoutManager';
|
import layoutManager from '../layoutManager';
|
||||||
import { playbackManager } from '../playback/playbackmanager';
|
import { playbackManager } from '../playback/playbackmanager';
|
||||||
import playMethodHelper from '../playback/playmethodhelper';
|
import playMethodHelper from '../playback/playmethodhelper';
|
||||||
import syncPlayManager from '../syncPlay/syncPlayManager';
|
import SyncPlay from 'SyncPlay';
|
||||||
import './playerstats.css';
|
import './playerstats.css';
|
||||||
import ServerConnections from '../ServerConnections';
|
import ServerConnections from '../ServerConnections';
|
||||||
|
|
||||||
@ -342,16 +342,22 @@ import ServerConnections from '../ServerConnections';
|
|||||||
|
|
||||||
function getSyncPlayStats() {
|
function getSyncPlayStats() {
|
||||||
const syncStats = [];
|
const syncStats = [];
|
||||||
const stats = syncPlayManager.getStats();
|
const stats = SyncPlay.Manager.getStats();
|
||||||
|
|
||||||
syncStats.push({
|
syncStats.push({
|
||||||
label: globalize.translate('LabelSyncPlayTimeOffset'),
|
label: globalize.translate('LabelSyncPlayTimeSyncDevice'),
|
||||||
value: stats.TimeOffset + globalize.translate('MillisecondsUnit')
|
value: stats.TimeSyncDevice
|
||||||
|
});
|
||||||
|
|
||||||
|
syncStats.push({
|
||||||
|
// TODO: clean old string 'LabelSyncPlayTimeOffset' from translations.
|
||||||
|
label: globalize.translate('LabelSyncPlayTimeSyncOffset'),
|
||||||
|
value: stats.TimeSyncOffset + ' ' + globalize.translate('MillisecondsUnit')
|
||||||
});
|
});
|
||||||
|
|
||||||
syncStats.push({
|
syncStats.push({
|
||||||
label: globalize.translate('LabelSyncPlayPlaybackDiff'),
|
label: globalize.translate('LabelSyncPlayPlaybackDiff'),
|
||||||
value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit')
|
value: stats.PlaybackDiff + ' ' + globalize.translate('MillisecondsUnit')
|
||||||
});
|
});
|
||||||
|
|
||||||
syncStats.push({
|
syncStats.push({
|
||||||
@ -433,7 +439,7 @@ import ServerConnections from '../ServerConnections';
|
|||||||
});
|
});
|
||||||
|
|
||||||
const apiClient = ServerConnections.getApiClient(playbackManager.currentItem(player).ServerId);
|
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({
|
categories.push({
|
||||||
stats: getSyncPlayStats(),
|
stats: getSyncPlayStats(),
|
||||||
name: globalize.translate('LabelSyncPlayInfo')
|
name: globalize.translate('LabelSyncPlayInfo')
|
||||||
|
@ -3,6 +3,7 @@ import dialogHelper from '../dialogHelper/dialogHelper';
|
|||||||
import loading from '../loading/loading';
|
import loading from '../loading/loading';
|
||||||
import layoutManager from '../layoutManager';
|
import layoutManager from '../layoutManager';
|
||||||
import { playbackManager } from '../playback/playbackmanager';
|
import { playbackManager } from '../playback/playbackmanager';
|
||||||
|
import SyncPlay from 'SyncPlay';
|
||||||
import * as userSettings from '../../scripts/settings/userSettings';
|
import * as userSettings from '../../scripts/settings/userSettings';
|
||||||
import { appRouter } from '../appRouter';
|
import { appRouter } from '../appRouter';
|
||||||
import globalize from '../../scripts/globalize';
|
import globalize from '../../scripts/globalize';
|
||||||
@ -117,7 +118,7 @@ import ServerConnections from '../ServerConnections';
|
|||||||
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => {
|
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) {
|
if ((editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) || SyncPlay.Manager.isSyncPlayEnabled()) {
|
||||||
html += `<option value="queue">${globalize.translate('AddToPlayQueue')}</option>`;
|
html += `<option value="queue">${globalize.translate('AddToPlayQueue')}</option>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,4 +180,3 @@ export default {
|
|||||||
cancelTimerWithConfirmation: cancelTimerWithConfirmation,
|
cancelTimerWithConfirmation: cancelTimerWithConfirmation,
|
||||||
cancelSeriesTimerWithConfirmation: cancelSeriesTimerWithConfirmation
|
cancelSeriesTimerWithConfirmation: cancelSeriesTimerWithConfirmation
|
||||||
};
|
};
|
||||||
|
|
||||||
|
221
src/components/syncPlay/core/controller.js
Normal file
221
src/components/syncPlay/core/controller.js
Normal file
@ -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;
|
240
src/components/syncPlay/core/helper.js
Normal file
240
src/components/syncPlay/core/helper.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
13
src/components/syncPlay/core/index.js
Normal file
13
src/components/syncPlay/core/index.js
Normal file
@ -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
|
||||||
|
}
|
||||||
|
};
|
479
src/components/syncPlay/core/manager.js
Normal file
479
src/components/syncPlay/core/manager.js
Normal file
@ -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;
|
577
src/components/syncPlay/core/playbackCore.js
Normal file
577
src/components/syncPlay/core/playbackCore.js
Normal file
@ -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;
|
73
src/components/syncPlay/core/players/factory.js
Normal file
73
src/components/syncPlay/core/players/factory.js
Normal file
@ -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;
|
305
src/components/syncPlay/core/players/genericPlayer.js
Normal file
305
src/components/syncPlay/core/players/genericPlayer.js
Normal file
@ -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;
|
372
src/components/syncPlay/core/queueCore.js
Normal file
372
src/components/syncPlay/core/queueCore.js
Normal file
@ -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;
|
78
src/components/syncPlay/core/timeSync/core.js
Normal file
78
src/components/syncPlay/core/timeSync/core.js
Normal file
@ -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;
|
39
src/components/syncPlay/core/timeSync/server.js
Normal file
39
src/components/syncPlay/core/timeSync/server.js
Normal file
@ -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;
|
@ -1,13 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Module that manages time syncing with server.
|
* Module that manages time syncing with another device.
|
||||||
* @module components/syncPlay/timeSyncManager
|
* @module components/syncPlay/core/timeSync/timeSync
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Events } from 'jellyfin-apiclient';
|
import { Events } from 'jellyfin-apiclient';
|
||||||
import ServerConnections from '../ServerConnections';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Time estimation
|
* Time estimation.
|
||||||
*/
|
*/
|
||||||
const NumberOfTrackedMeasurements = 8;
|
const NumberOfTrackedMeasurements = 8;
|
||||||
const PollingIntervalGreedy = 1000; // milliseconds
|
const PollingIntervalGreedy = 1000; // milliseconds
|
||||||
@ -21,8 +20,8 @@ class Measurement {
|
|||||||
/**
|
/**
|
||||||
* Creates a new measurement.
|
* Creates a new measurement.
|
||||||
* @param {Date} requestSent Client's timestamp of the request transmission
|
* @param {Date} requestSent Client's timestamp of the request transmission
|
||||||
* @param {Date} requestReceived Server's timestamp of the request reception
|
* @param {Date} requestReceived Remote's timestamp of the request reception
|
||||||
* @param {Date} responseSent Server's timestamp of the response transmission
|
* @param {Date} responseSent Remote's timestamp of the response transmission
|
||||||
* @param {Date} responseReceived Client's timestamp of the response reception
|
* @param {Date} responseReceived Client's timestamp of the response reception
|
||||||
*/
|
*/
|
||||||
constructor(requestSent, requestReceived, responseSent, responseReceived) {
|
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;
|
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);
|
return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ping time.
|
* Get ping time, in milliseconds.
|
||||||
*/
|
*/
|
||||||
getPing () {
|
getPing() {
|
||||||
return this.getDelay() / 2;
|
return this.getDelay() / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class that manages time syncing with server.
|
* Class that manages time syncing with remote entity.
|
||||||
*/
|
*/
|
||||||
class TimeSyncManager {
|
class TimeSync {
|
||||||
constructor() {
|
constructor(syncPlayManager) {
|
||||||
|
this.manager = syncPlayManager;
|
||||||
this.pingStop = true;
|
this.pingStop = true;
|
||||||
this.pollingInterval = PollingIntervalGreedy;
|
this.pollingInterval = PollingIntervalGreedy;
|
||||||
this.poller = null;
|
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.
|
* @returns {number} The time offset.
|
||||||
*/
|
*/
|
||||||
getTimeOffset () {
|
getTimeOffset() {
|
||||||
return this.measurement ? this.measurement.getOffset() : 0;
|
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.
|
* @returns {number} The ping time.
|
||||||
*/
|
*/
|
||||||
getPing () {
|
getPing() {
|
||||||
return this.measurement ? this.measurement.getPing() : 0;
|
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.
|
* @param {Measurement} measurement The new measurement.
|
||||||
*/
|
*/
|
||||||
updateTimeOffset(measurement) {
|
updateTimeOffset(measurement) {
|
||||||
@ -101,53 +101,68 @@ class TimeSyncManager {
|
|||||||
this.measurements.shift();
|
this.measurements.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick measurement with minimum delay
|
// Pick measurement with minimum delay.
|
||||||
const sortedMeasurements = this.measurements.slice(0);
|
const sortedMeasurements = this.measurements.slice(0);
|
||||||
sortedMeasurements.sort((a, b) => a.getDelay() - b.getDelay());
|
sortedMeasurements.sort((a, b) => a.getDelay() - b.getDelay());
|
||||||
this.measurement = sortedMeasurements[0];
|
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() {
|
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 = setTimeout(() => {
|
||||||
this.poller = null;
|
this.poller = null;
|
||||||
const apiClient = ServerConnections.currentApiClient();
|
this.requestPing()
|
||||||
const requestSent = new Date();
|
.then((result) => this.onPingResponseCallback(result))
|
||||||
apiClient.getServerTime().then((response) => {
|
.catch((error) => this.onPingRequestErrorCallback(error))
|
||||||
const responseReceived = new Date();
|
.finally(() => this.internalRequestPing());
|
||||||
response.json().then((data) => {
|
|
||||||
const requestReceived = new Date(data.RequestReceptionTime);
|
|
||||||
const responseSent = new Date(data.ResponseTransmissionTime);
|
|
||||||
|
|
||||||
const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived);
|
|
||||||
this.updateTimeOffset(measurement);
|
|
||||||
|
|
||||||
// Avoid overloading server
|
|
||||||
if (this.pings >= GreedyPingCount) {
|
|
||||||
this.pollingInterval = PollingIntervalLowProfile;
|
|
||||||
} else {
|
|
||||||
this.pings++;
|
|
||||||
}
|
|
||||||
|
|
||||||
Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]);
|
|
||||||
});
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
Events.trigger(this, 'update', [error, null, null]);
|
|
||||||
}).finally(() => {
|
|
||||||
this.requestPing();
|
|
||||||
});
|
|
||||||
}, this.pollingInterval);
|
}, 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.
|
* Drops accumulated measurements.
|
||||||
*/
|
*/
|
||||||
resetMeasurements () {
|
resetMeasurements() {
|
||||||
this.measurement = null;
|
this.measurement = null;
|
||||||
this.measurements = [];
|
this.measurements = [];
|
||||||
}
|
}
|
||||||
@ -156,13 +171,15 @@ class TimeSyncManager {
|
|||||||
* Starts the time poller.
|
* Starts the time poller.
|
||||||
*/
|
*/
|
||||||
startPing() {
|
startPing() {
|
||||||
this.requestPing();
|
this.pingStop = false;
|
||||||
|
this.internalRequestPing();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the time poller.
|
* Stops the time poller.
|
||||||
*/
|
*/
|
||||||
stopPing() {
|
stopPing() {
|
||||||
|
this.pingStop = true;
|
||||||
if (this.poller) {
|
if (this.poller) {
|
||||||
clearTimeout(this.poller);
|
clearTimeout(this.poller);
|
||||||
this.poller = null;
|
this.poller = null;
|
||||||
@ -180,25 +197,24 @@ class TimeSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts server time to local time.
|
* Converts remote time to local time.
|
||||||
* @param {Date} server The time to convert.
|
* @param {Date} remote The time to convert.
|
||||||
* @returns {Date} Local time.
|
* @returns {Date} Local time.
|
||||||
*/
|
*/
|
||||||
serverDateToLocal(server) {
|
remoteDateToLocal(remote) {
|
||||||
// server - local = offset
|
// remote - local = offset
|
||||||
return new Date(server.getTime() - this.getTimeOffset());
|
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.
|
* @param {Date} local The time to convert.
|
||||||
* @returns {Date} Server time.
|
* @returns {Date} Remote time.
|
||||||
*/
|
*/
|
||||||
localDateToServer(local) {
|
localDateToRemote(local) {
|
||||||
// server - local = offset
|
// remote - local = offset
|
||||||
return new Date(local.getTime() + this.getTimeOffset());
|
return new Date(local.getTime() + this.getTimeOffset());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TimeSyncManager singleton. */
|
export default TimeSync;
|
||||||
export default new TimeSyncManager();
|
|
@ -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')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
@ -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();
|
|
189
src/components/syncPlay/ui/groupSelectionMenu.js
Normal file
189
src/components/syncPlay/ui/groupSelectionMenu.js
Normal file
@ -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;
|
19
src/components/syncPlay/ui/players/htmlAudioPlayer.js
Normal file
19
src/components/syncPlay/ui/players/htmlAudioPlayer.js
Normal file
@ -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;
|
155
src/components/syncPlay/ui/players/htmlVideoPlayer.js
Normal file
155
src/components/syncPlay/ui/players/htmlVideoPlayer.js
Normal file
@ -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;
|
444
src/components/syncPlay/ui/players/noActivePlayer.js
Normal file
444
src/components/syncPlay/ui/players/noActivePlayer.js
Normal file
@ -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;
|
202
src/components/syncPlay/ui/players/queueManager.js
Normal file
202
src/components/syncPlay/ui/players/queueManager.js
Normal file
@ -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;
|
34
src/components/syncPlay/ui/syncPlayToasts.js
Normal file
34
src/components/syncPlay/ui/syncPlayToasts.js
Normal file
@ -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;
|
@ -1,4 +1,10 @@
|
|||||||
<div id="videoOsdPage" data-role="page" class="page libraryPage" data-backbutton="true">
|
<div id="videoOsdPage" data-role="page" class="page libraryPage" data-backbutton="true">
|
||||||
|
<div class="syncPlayContainer">
|
||||||
|
<div id="syncPlayIcon" class="syncPlayIconCircle">
|
||||||
|
<span class="primary-icon material-icons play"></span>
|
||||||
|
<span class="secondary-icon material-icons play"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="upNextContainer hide"></div>
|
<div class="upNextContainer hide"></div>
|
||||||
<div class="videoOsdBottom videoOsdBottom-maincontrols">
|
<div class="videoOsdBottom videoOsdBottom-maincontrols">
|
||||||
<div class="osdControls">
|
<div class="osdControls">
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { playbackManager } from '../../../components/playback/playbackmanager';
|
import { playbackManager } from '../../../components/playback/playbackmanager';
|
||||||
|
import SyncPlay from 'SyncPlay';
|
||||||
import dom from '../../../scripts/dom';
|
import dom from '../../../scripts/dom';
|
||||||
import inputManager from '../../../scripts/inputManager';
|
import inputManager from '../../../scripts/inputManager';
|
||||||
import mouseManager from '../../../scripts/mouseManager';
|
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 */
|
/* eslint-enable indent */
|
||||||
|
@ -22,6 +22,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkboxContainer-noText {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.checkboxListContainer {
|
.checkboxListContainer {
|
||||||
margin-bottom: 1.8em;
|
margin-bottom: 1.8em;
|
||||||
}
|
}
|
||||||
@ -63,6 +67,10 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkboxContainer-noText .checkboxOutline {
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.checkboxIcon {
|
.checkboxIcon {
|
||||||
font-size: 1.6em;
|
font-size: 1.6em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -73,16 +81,19 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline > .checkboxIcon-checked,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked {
|
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked {
|
||||||
/* background color set by theme */
|
/* background color set by theme */
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline > .checkboxIcon-unchecked,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-unchecked {
|
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-unchecked {
|
||||||
/* background color set by theme */
|
/* background color set by theme */
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked[disabled] + .checkboxOutline > .checkboxIcon,
|
||||||
.emby-checkbox:checked[disabled] + span + .checkboxOutline > .checkboxIcon {
|
.emby-checkbox:checked[disabled] + span + .checkboxOutline > .checkboxIcon {
|
||||||
background-color: rgba(0, 0, 0, 0.26);
|
background-color: rgba(0, 0, 0, 0.26);
|
||||||
}
|
}
|
||||||
|
@ -65,7 +65,9 @@ import 'webcomponents.js/webcomponents-lite';
|
|||||||
const uncheckedHtml = '<span class="material-icons checkboxIcon checkboxIcon-unchecked ' + uncheckedIcon + '"></span>';
|
const uncheckedHtml = '<span class="material-icons checkboxIcon checkboxIcon-unchecked ' + uncheckedIcon + '"></span>';
|
||||||
labelElement.insertAdjacentHTML('beforeend', '<span class="' + outlineClass + '">' + checkHtml + uncheckedHtml + '</span>');
|
labelElement.insertAdjacentHTML('beforeend', '<span class="' + outlineClass + '">' + checkHtml + uncheckedHtml + '</span>');
|
||||||
|
|
||||||
labelTextElement.classList.add('checkboxLabel');
|
if (labelTextElement) {
|
||||||
|
labelTextElement.classList.add('checkboxLabel');
|
||||||
|
}
|
||||||
|
|
||||||
this.addEventListener('keydown', onKeyDown);
|
this.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
@ -6,8 +6,8 @@ import viewManager from '../components/viewManager/viewManager';
|
|||||||
import { appRouter } from '../components/appRouter';
|
import { appRouter } from '../components/appRouter';
|
||||||
import { appHost } from '../components/apphost';
|
import { appHost } from '../components/apphost';
|
||||||
import { playbackManager } from '../components/playback/playbackmanager';
|
import { playbackManager } from '../components/playback/playbackmanager';
|
||||||
import syncPlayManager from '../components/syncPlay/syncPlayManager';
|
import SyncPlay from 'SyncPlay';
|
||||||
import { show as groupSelectionMenuShow } from '../components/syncPlay/groupSelectionMenu';
|
import groupSelectionMenu from '../components/syncPlay/ui/groupSelectionMenu';
|
||||||
import browser from './browser';
|
import browser from './browser';
|
||||||
import globalize from './globalize';
|
import globalize from './globalize';
|
||||||
import imageHelper from './imagehelper';
|
import imageHelper from './imagehelper';
|
||||||
@ -230,7 +230,7 @@ import Headroom from 'headroom.js';
|
|||||||
|
|
||||||
function onSyncButtonClicked() {
|
function onSyncButtonClicked() {
|
||||||
const btn = this;
|
const btn = this;
|
||||||
groupSelectionMenuShow(btn);
|
groupSelectionMenu.show(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSyncPlayEnabled(event, enabled) {
|
function onSyncPlayEnabled(event, enabled) {
|
||||||
@ -1000,8 +1000,8 @@ import Headroom from 'headroom.js';
|
|||||||
|
|
||||||
Events.on(playbackManager, 'playerchange', updateCastIcon);
|
Events.on(playbackManager, 'playerchange', updateCastIcon);
|
||||||
|
|
||||||
Events.on(syncPlayManager, 'enabled', onSyncPlayEnabled);
|
Events.on(SyncPlay.Manager, 'enabled', onSyncPlayEnabled);
|
||||||
Events.on(syncPlayManager, 'syncing', onSyncPlaySyncing);
|
Events.on(SyncPlay.Manager, 'syncing', onSyncPlaySyncing);
|
||||||
|
|
||||||
loadNavDrawer();
|
loadNavDrawer();
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { playbackManager } from '../components/playback/playbackmanager';
|
import { playbackManager } from '../components/playback/playbackmanager';
|
||||||
import syncPlayManager from '../components/syncPlay/syncPlayManager';
|
import SyncPlay from 'SyncPlay';
|
||||||
import { Events } from 'jellyfin-apiclient';
|
import { Events } from 'jellyfin-apiclient';
|
||||||
import inputManager from '../scripts/inputManager';
|
import inputManager from '../scripts/inputManager';
|
||||||
import focusManager from '../components/focusManager';
|
import focusManager from '../components/focusManager';
|
||||||
@ -194,9 +194,9 @@ function onMessageReceived(e, msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.MessageType === 'SyncPlayCommand') {
|
} else if (msg.MessageType === 'SyncPlayCommand') {
|
||||||
syncPlayManager.processCommand(msg.Data, apiClient);
|
SyncPlay.Manager.processCommand(msg.Data, apiClient);
|
||||||
} else if (msg.MessageType === 'SyncPlayGroupUpdate') {
|
} else if (msg.MessageType === 'SyncPlayGroupUpdate') {
|
||||||
syncPlayManager.processGroupUpdate(msg.Data, apiClient);
|
SyncPlay.Manager.processGroupUpdate(msg.Data, apiClient);
|
||||||
} else {
|
} else {
|
||||||
Events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]);
|
Events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]);
|
||||||
}
|
}
|
||||||
|
@ -30,6 +30,12 @@ import { navigate, pageClassOn, serverAddress } from './clientUtils';
|
|||||||
import '../libraries/screensavermanager';
|
import '../libraries/screensavermanager';
|
||||||
import './serverNotifications';
|
import './serverNotifications';
|
||||||
import '../components/playback/playerSelectionMenu';
|
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
|
// TODO: Move this elsewhere
|
||||||
window.getWindowLocationSearch = function(win) {
|
window.getWindowLocationSearch = function(win) {
|
||||||
@ -117,6 +123,7 @@ function onGlobalizeInit() {
|
|||||||
import('../assets/css/librarybrowser.css');
|
import('../assets/css/librarybrowser.css');
|
||||||
|
|
||||||
loadPlugins().then(function () {
|
loadPlugins().then(function () {
|
||||||
|
initSyncPlay();
|
||||||
onAppReady();
|
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() {
|
function onAppReady() {
|
||||||
console.debug('begin onAppReady');
|
console.debug('begin onAppReady');
|
||||||
|
|
||||||
|
@ -842,13 +842,18 @@
|
|||||||
"LabelSyncPlayAccessCreateAndJoinGroups": "Allow user to create and join groups",
|
"LabelSyncPlayAccessCreateAndJoinGroups": "Allow user to create and join groups",
|
||||||
"LabelSyncPlayAccessJoinGroups": "Allow user to join groups",
|
"LabelSyncPlayAccessJoinGroups": "Allow user to join groups",
|
||||||
"LabelSyncPlayAccessNone": "Disabled for this user",
|
"LabelSyncPlayAccessNone": "Disabled for this user",
|
||||||
|
"LabelSyncPlayHaltPlayback": "Stop local playback",
|
||||||
|
"LabelSyncPlayHaltPlaybackDescription": "And ignore current playlist updates",
|
||||||
"LabelSyncPlayLeaveGroup": "Leave group",
|
"LabelSyncPlayLeaveGroup": "Leave group",
|
||||||
"LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay",
|
"LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay",
|
||||||
"LabelSyncPlayNewGroup": "New group",
|
"LabelSyncPlayNewGroup": "New group",
|
||||||
"LabelSyncPlayNewGroupDescription": "Create a new group",
|
"LabelSyncPlayNewGroupDescription": "Create a new group",
|
||||||
"LabelSyncPlayPlaybackDiff": "Playback time difference:",
|
"LabelSyncPlayPlaybackDiff": "Playback time difference:",
|
||||||
|
"LabelSyncPlayResumePlayback": "Resume local playback",
|
||||||
|
"LabelSyncPlayResumePlaybackDescription": "Join back group playback",
|
||||||
"LabelSyncPlaySyncMethod": "Sync method:",
|
"LabelSyncPlaySyncMethod": "Sync method:",
|
||||||
"LabelSyncPlayTimeOffset": "Time offset with the server:",
|
"LabelSyncPlayTimeSyncDevice": "Time syncing with:",
|
||||||
|
"LabelSyncPlayTimeSyncOffset": "Time offset:",
|
||||||
"LabelTag": "Tag:",
|
"LabelTag": "Tag:",
|
||||||
"LabelTagline": "Tagline:",
|
"LabelTagline": "Tagline:",
|
||||||
"LabelTextBackgroundColor": "Text background color:",
|
"LabelTextBackgroundColor": "Text background color:",
|
||||||
@ -1023,7 +1028,8 @@
|
|||||||
"MessageSyncPlayErrorNoActivePlayer": "No active player found. SyncPlay has been disabled.",
|
"MessageSyncPlayErrorNoActivePlayer": "No active player found. SyncPlay has been disabled.",
|
||||||
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist.",
|
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist.",
|
||||||
"MessageSyncPlayGroupWait": "<b>{0}</b> is buffering…",
|
"MessageSyncPlayGroupWait": "<b>{0}</b> 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.",
|
"MessageSyncPlayLibraryAccessDenied": "Access to this content is restricted.",
|
||||||
"MessageSyncPlayNoGroupsAvailable": "No groups available. Start playing something first.",
|
"MessageSyncPlayNoGroupsAvailable": "No groups available. Start playing something first.",
|
||||||
"MessageSyncPlayPlaybackPermissionRequired": "Playback permission required.",
|
"MessageSyncPlayPlaybackPermissionRequired": "Playback permission required.",
|
||||||
@ -1352,6 +1358,7 @@
|
|||||||
"Sunday": "Sunday",
|
"Sunday": "Sunday",
|
||||||
"Sync": "Sync",
|
"Sync": "Sync",
|
||||||
"SyncPlayAccessHelp": "Select the level of access this user has to the SyncPlay feature. SyncPlay enables to sync playback with other devices.",
|
"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.",
|
"SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.",
|
||||||
"TabAccess": "Access",
|
"TabAccess": "Access",
|
||||||
"TabAdvanced": "Advanced",
|
"TabAdvanced": "Advanced",
|
||||||
|
@ -294,15 +294,18 @@ html {
|
|||||||
border: 0.07em solid rgba(0, 0, 0, 0.158);
|
border: 0.07em solid rgba(0, 0, 0, 0.158);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.emby-select-withcolor:focus {
|
.emby-select-withcolor:focus {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus + .checkboxOutline,
|
||||||
.emby-checkbox:focus + span + .checkboxOutline {
|
.emby-checkbox:focus + span + .checkboxOutline {
|
||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline {
|
.emby-checkbox:checked + span + .checkboxOutline {
|
||||||
background-color: #00a4dc;
|
background-color: #00a4dc;
|
||||||
}
|
}
|
||||||
@ -321,6 +324,7 @@ html {
|
|||||||
margin: 0.4rem 0.5rem 0.4rem 0.5rem;
|
margin: 0.4rem 0.5rem 0.4rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
|
||||||
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
@ -308,19 +308,23 @@ html {
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline {
|
.emby-checkbox:checked + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus + .checkboxOutline,
|
||||||
.emby-checkbox:focus + span + .checkboxOutline {
|
.emby-checkbox:focus + span + .checkboxOutline {
|
||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.itemProgressBarForeground {
|
.itemProgressBarForeground {
|
||||||
background-color: #00a4dc;
|
background-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
|
||||||
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
@ -289,19 +289,23 @@ html {
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline {
|
.emby-checkbox:checked + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus + .checkboxOutline,
|
||||||
.emby-checkbox:focus + span + .checkboxOutline {
|
.emby-checkbox:focus + span + .checkboxOutline {
|
||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.itemProgressBarForeground {
|
.itemProgressBarForeground {
|
||||||
background-color: #00a4dc;
|
background-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
|
||||||
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
@ -293,20 +293,24 @@ html {
|
|||||||
border: 0.07em solid rgba(0, 0, 0, 0.158);
|
border: 0.07em solid rgba(0, 0, 0, 0.158);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.emby-select-withcolor:focus {
|
.emby-select-withcolor:focus {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus + .checkboxOutline,
|
||||||
.emby-checkbox:focus + span + .checkboxOutline {
|
.emby-checkbox:focus + span + .checkboxOutline {
|
||||||
border-color: #000;
|
border-color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.itemProgressBarForeground {
|
.itemProgressBarForeground {
|
||||||
background-color: #00a4dc;
|
background-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
|
||||||
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
@ -105,11 +105,13 @@ progress::-webkit-progress-value {
|
|||||||
background: #ff77f1;
|
background: #ff77f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div[data-role=controlgroup] .controlGroupButton.ui-btn-active,
|
||||||
div[data-role=controlgroup] a.ui-btn-active {
|
div[data-role=controlgroup] a.ui-btn-active {
|
||||||
background: #55828b !important;
|
background: #55828b !important;
|
||||||
color: #e1e5f2 !important;
|
color: #e1e5f2 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controlGroupButton,
|
||||||
a[data-role=button] {
|
a[data-role=button] {
|
||||||
background: rgba(2, 43, 58, 0.521) !important;
|
background: rgba(2, 43, 58, 0.521) !important;
|
||||||
}
|
}
|
||||||
@ -396,19 +398,23 @@ a[data-role=button] {
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline {
|
.emby-checkbox:checked + span + .checkboxOutline {
|
||||||
background-color: #030322;
|
background-color: #030322;
|
||||||
border: 0.14em solid rgb(72, 195, 200);
|
border: 0.14em solid rgb(72, 195, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline > .minimalCheckboxIcon-checked,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked {
|
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked {
|
||||||
color: rgb(12, 232, 214);
|
color: rgb(12, 232, 214);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus + .checkboxOutline,
|
||||||
.emby-checkbox:focus + span + .checkboxOutline {
|
.emby-checkbox:focus + span + .checkboxOutline {
|
||||||
border-color: #ff77f1;
|
border-color: #ff77f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
|
||||||
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
||||||
border: 0.14em solid #ff77f1;
|
border: 0.14em solid #ff77f1;
|
||||||
}
|
}
|
||||||
|
@ -276,20 +276,24 @@ html {
|
|||||||
border: 0.07em solid rgba(255, 255, 255, 0.135);
|
border: 0.07em solid rgba(255, 255, 255, 0.135);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.emby-select-withcolor:focus {
|
.emby-select-withcolor:focus {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus + .checkboxOutline,
|
||||||
.emby-checkbox:focus + span + .checkboxOutline {
|
.emby-checkbox:focus + span + .checkboxOutline {
|
||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:checked + .checkboxOutline,
|
||||||
.emby-checkbox:checked + span + .checkboxOutline,
|
.emby-checkbox:checked + span + .checkboxOutline,
|
||||||
.itemProgressBarForeground {
|
.itemProgressBarForeground {
|
||||||
background-color: #00a4dc;
|
background-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
|
||||||
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
|
||||||
border-color: #00a4dc;
|
border-color: #00a4dc;
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,10 @@ module.exports = {
|
|||||||
resolve: {
|
resolve: {
|
||||||
modules: [
|
modules: [
|
||||||
path.resolve(__dirname, 'node_modules')
|
path.resolve(__dirname, 'node_modules')
|
||||||
]
|
],
|
||||||
|
alias: {
|
||||||
|
'SyncPlay': path.resolve(__dirname, 'src/components/syncPlay/core')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CleanWebpackPlugin(),
|
new CleanWebpackPlugin(),
|
||||||
|
Loading…
Reference in New Issue
Block a user