mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge branch 'master' into es6-webos
This commit is contained in:
commit
2bfd714eb5
52 changed files with 3972 additions and 1198 deletions
|
@ -1,5 +0,0 @@
|
|||
version: 1
|
||||
update_configs:
|
||||
- package_manager: "javascript"
|
||||
directory: "/"
|
||||
update_schedule: "weekly"
|
7
.github/dependabot.yaml
vendored
Normal file
7
.github/dependabot.yaml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
1
.github/workflows/merge-conflicts.yml
vendored
1
.github/workflows/merge-conflicts.yml
vendored
|
@ -7,6 +7,7 @@ on:
|
|||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'jellyfin/jellyfin-web'
|
||||
steps:
|
||||
- uses: mschilde/auto-label-merge-conflicts@master
|
||||
with:
|
||||
|
|
|
@ -255,3 +255,118 @@
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.syncPlayContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.primary-icon {
|
||||
position: absolute;
|
||||
font-size: 64px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.primary-icon.spin {
|
||||
font-size: 76px !important;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.secondary-icon {
|
||||
position: absolute;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.secondary-icon.centered {
|
||||
font-size: 28px !important;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.secondary-icon.shifted {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
.syncPlayIconCircle {
|
||||
position: relative;
|
||||
visibility: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 50%;
|
||||
margin: 60px;
|
||||
height: 96px;
|
||||
width: 96px;
|
||||
|
||||
color: rgba(0, 164, 220, 0);
|
||||
background: rgba(0, 164, 220, 0);
|
||||
box-shadow: 0 0 0 0 rgba(0, 164, 220, 0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.syncPlayIconCircle.oneShotPulse {
|
||||
animation: pulse 1.5s 1;
|
||||
}
|
||||
|
||||
.syncPlayIconCircle.infinitePulse {
|
||||
animation: infinite-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
color: rgba(0, 164, 220, 0.7);
|
||||
background: rgba(0, 164, 220, 0.3);
|
||||
box-shadow: 0 0 0 0 rgba(0, 164, 220, 0.3);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1);
|
||||
color: rgba(0, 164, 220, 0);
|
||||
background: rgba(0, 164, 220, 0);
|
||||
box-shadow: 0 0 0 60px rgba(0, 164, 220, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
color: rgba(0, 164, 220, 0);
|
||||
background: rgba(0, 164, 220, 0);
|
||||
box-shadow: 0 0 0 0 rgba(0, 164, 220, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes infinite-pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
color: rgba(0, 164, 220, 0.7);
|
||||
background: rgba(0, 164, 220, 0.3);
|
||||
box-shadow: 0 0 0 0 rgba(0, 164, 220, 0.3);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1);
|
||||
color: rgba(0, 164, 220, 0.6);
|
||||
background: rgba(0, 164, 220, 0);
|
||||
box-shadow: 0 0 0 60px rgba(0, 164, 220, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
color: rgba(0, 164, 220, 0.7);
|
||||
background: rgba(0, 164, 220, 0.3);
|
||||
box-shadow: 0 0 0 0 rgba(0, 164, 220, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ class AppRouter {
|
|||
currentViewLoadRequest;
|
||||
firstConnectionResult;
|
||||
forcedLogoutMsg;
|
||||
handleAnchorClick = page.clickHandler;
|
||||
isDummyBackToHome;
|
||||
msgTimeout;
|
||||
popstateOccurred = false;
|
||||
|
|
|
@ -1876,6 +1876,9 @@ class PlaybackManager {
|
|||
}
|
||||
}
|
||||
|
||||
self.translateItemsForPlayback = translateItemsForPlayback;
|
||||
self.getItemsForPlayback = getItemsForPlayback;
|
||||
|
||||
self.play = function (options) {
|
||||
normalizePlayOptions(options);
|
||||
|
||||
|
@ -2504,29 +2507,38 @@ class PlaybackManager {
|
|||
})[0];
|
||||
}
|
||||
|
||||
self.getItemFromPlaylistItemId = function (playlistItemId) {
|
||||
let item;
|
||||
let itemIndex;
|
||||
const playlist = self._playQueueManager.getPlaylist();
|
||||
|
||||
for (let i = 0, length = playlist.length; i < length; i++) {
|
||||
if (playlist[i].PlaylistItemId === playlistItemId) {
|
||||
item = playlist[i];
|
||||
itemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Item: item,
|
||||
Index: itemIndex
|
||||
};
|
||||
};
|
||||
|
||||
self.setCurrentPlaylistItem = function (playlistItemId, player) {
|
||||
player = player || self._currentPlayer;
|
||||
if (player && !enableLocalPlaylistManagement(player)) {
|
||||
return player.setCurrentPlaylistItem(playlistItemId);
|
||||
}
|
||||
|
||||
let newItem;
|
||||
let newItemIndex;
|
||||
const playlist = self._playQueueManager.getPlaylist();
|
||||
const newItem = self.getItemFromPlaylistItemId(playlistItemId);
|
||||
|
||||
for (let i = 0, length = playlist.length; i < length; i++) {
|
||||
if (playlist[i].PlaylistItemId === playlistItemId) {
|
||||
newItem = playlist[i];
|
||||
newItemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (newItem.Item) {
|
||||
const newItemPlayOptions = newItem.Item.playOptions || getDefaultPlayOptions();
|
||||
|
||||
if (newItem) {
|
||||
const newItemPlayOptions = newItem.playOptions || getDefaultPlayOptions();
|
||||
|
||||
playInternal(newItem, newItemPlayOptions, function () {
|
||||
setPlaylistState(newItem.PlaylistItemId, newItemIndex);
|
||||
playInternal(newItem.Item, newItemPlayOptions, function () {
|
||||
setPlaylistState(newItem.Item.PlaylistItemId, newItem.Index);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -2905,6 +2917,8 @@ class PlaybackManager {
|
|||
}
|
||||
}
|
||||
|
||||
Events.trigger(self, 'playbackerror', [errorType]);
|
||||
|
||||
const displayErrorCode = 'NoCompatibleStream';
|
||||
onPlaybackStopped.call(player, e, displayErrorCode);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import globalize from '../../scripts/globalize';
|
|||
import layoutManager from '../layoutManager';
|
||||
import { playbackManager } from '../playback/playbackmanager';
|
||||
import playMethodHelper from '../playback/playmethodhelper';
|
||||
import syncPlayManager from '../syncPlay/syncPlayManager';
|
||||
import SyncPlay from '../../components/syncPlay/core';
|
||||
import './playerstats.css';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
|
||||
|
@ -342,16 +342,22 @@ import ServerConnections from '../ServerConnections';
|
|||
|
||||
function getSyncPlayStats() {
|
||||
const syncStats = [];
|
||||
const stats = syncPlayManager.getStats();
|
||||
const stats = SyncPlay.Manager.getStats();
|
||||
|
||||
syncStats.push({
|
||||
label: globalize.translate('LabelSyncPlayTimeOffset'),
|
||||
value: stats.TimeOffset + globalize.translate('MillisecondsUnit')
|
||||
label: globalize.translate('LabelSyncPlayTimeSyncDevice'),
|
||||
value: stats.TimeSyncDevice
|
||||
});
|
||||
|
||||
syncStats.push({
|
||||
// TODO: clean old string 'LabelSyncPlayTimeOffset' from translations.
|
||||
label: globalize.translate('LabelSyncPlayTimeSyncOffset'),
|
||||
value: stats.TimeSyncOffset + ' ' + globalize.translate('MillisecondsUnit')
|
||||
});
|
||||
|
||||
syncStats.push({
|
||||
label: globalize.translate('LabelSyncPlayPlaybackDiff'),
|
||||
value: stats.PlaybackDiff + globalize.translate('MillisecondsUnit')
|
||||
value: stats.PlaybackDiff + ' ' + globalize.translate('MillisecondsUnit')
|
||||
});
|
||||
|
||||
syncStats.push({
|
||||
|
@ -433,7 +439,7 @@ import ServerConnections from '../ServerConnections';
|
|||
});
|
||||
|
||||
const apiClient = ServerConnections.getApiClient(playbackManager.currentItem(player).ServerId);
|
||||
if (syncPlayManager.isSyncPlayEnabled() && apiClient.isMinServerVersion('10.6.0')) {
|
||||
if (SyncPlay.Manager.isSyncPlayEnabled() && apiClient.isMinServerVersion('10.6.0')) {
|
||||
categories.push({
|
||||
stats: getSyncPlayStats(),
|
||||
name: globalize.translate('LabelSyncPlayInfo')
|
||||
|
|
|
@ -3,6 +3,7 @@ import dialogHelper from '../dialogHelper/dialogHelper';
|
|||
import loading from '../loading/loading';
|
||||
import layoutManager from '../layoutManager';
|
||||
import { playbackManager } from '../playback/playbackmanager';
|
||||
import SyncPlay from '../../components/syncPlay/core';
|
||||
import * as userSettings from '../../scripts/settings/userSettings';
|
||||
import { appRouter } from '../appRouter';
|
||||
import globalize from '../../scripts/globalize';
|
||||
|
@ -117,7 +118,7 @@ import ServerConnections from '../ServerConnections';
|
|||
apiClient.getItems(apiClient.getCurrentUserId(), options).then(result => {
|
||||
let html = '';
|
||||
|
||||
if (editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) {
|
||||
if ((editorOptions.enableAddToPlayQueue !== false && playbackManager.isPlaying()) || SyncPlay.Manager.isSyncPlayEnabled()) {
|
||||
html += `<option value="queue">${globalize.translate('AddToPlayQueue')}</option>`;
|
||||
}
|
||||
|
||||
|
|
|
@ -52,19 +52,16 @@ export function getVideoQualityOptions(options) {
|
|||
qualityOptions.push({ name: '360p - 420 kbps', maxHeight: 360, bitrate: 420000 });
|
||||
|
||||
if (maxStreamingBitrate) {
|
||||
let selectedIndex = -1;
|
||||
let selectedIndex = qualityOptions.length - 1;
|
||||
for (let i = 0, length = qualityOptions.length; i < length; i++) {
|
||||
const option = qualityOptions[i];
|
||||
|
||||
if (selectedIndex === -1 && option.bitrate <= maxStreamingBitrate) {
|
||||
if (option.bitrate > 0 && option.bitrate <= maxStreamingBitrate) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
selectedIndex = qualityOptions.length - 1;
|
||||
}
|
||||
|
||||
const currentQualityOption = qualityOptions[selectedIndex];
|
||||
|
||||
if (!options.isAutomaticBitrateEnabled) {
|
||||
|
@ -103,19 +100,16 @@ export function getAudioQualityOptions(options) {
|
|||
qualityOptions.push({ name: '64 kbps', bitrate: 64000 });
|
||||
|
||||
if (maxStreamingBitrate) {
|
||||
let selectedIndex = -1;
|
||||
let selectedIndex = qualityOptions.length - 1;
|
||||
for (let i = 0, length = qualityOptions.length; i < length; i++) {
|
||||
const option = qualityOptions[i];
|
||||
|
||||
if (selectedIndex === -1 && option.bitrate <= maxStreamingBitrate) {
|
||||
if (option.bitrate > 0 && option.bitrate <= maxStreamingBitrate) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
selectedIndex = qualityOptions.length - 1;
|
||||
}
|
||||
|
||||
const currentQualityOption = qualityOptions[selectedIndex];
|
||||
|
||||
if (!options.isAutomaticBitrateEnabled) {
|
||||
|
|
|
@ -180,4 +180,3 @@ export default {
|
|||
cancelTimerWithConfirmation: cancelTimerWithConfirmation,
|
||||
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 Controller {
|
||||
constructor() {
|
||||
this.manager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller.
|
||||
* @param {Manager} 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.requestSyncPlaySetNewQueue({
|
||||
PlayingQueue: queue,
|
||||
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,
|
||||
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,
|
||||
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 item from playlist in SyncPlay group.
|
||||
*/
|
||||
nextItem() {
|
||||
const apiClient = this.manager.getApiClient();
|
||||
apiClient.requestSyncPlayNextItem({
|
||||
PlaylistItemId: this.manager.getQueueCore().getCurrentPlaylistItemId()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays previous item from playlist in SyncPlay group.
|
||||
*/
|
||||
previousItem() {
|
||||
const apiClient = this.manager.getApiClient();
|
||||
apiClient.requestSyncPlayPreviousItem({
|
||||
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 Controller;
|
238
src/components/syncPlay/core/Helper.js
Normal file
238
src/components/syncPlay/core/Helper.js
Normal file
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* 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) {
|
||||
let index = result.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);
|
||||
}
|
||||
}
|
481
src/components/syncPlay/core/Manager.js
Normal file
481
src/components/syncPlay/core/Manager.js
Normal file
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* Module that manages the SyncPlay feature.
|
||||
* @module components/syncPlay/core/Manager
|
||||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import * as Helper from './Helper';
|
||||
import TimeSyncCore from './timeSync/TimeSyncCore';
|
||||
import PlaybackCore from './PlaybackCore';
|
||||
import QueueCore from './QueueCore';
|
||||
import Controller from './Controller';
|
||||
|
||||
/**
|
||||
* Class that manages the SyncPlay feature.
|
||||
*/
|
||||
class Manager {
|
||||
/**
|
||||
* Creates an instance of SyncPlay Manager.
|
||||
* @param {PlayerFactory} playerFactory The PlayerFactory instance.
|
||||
*/
|
||||
constructor(playerFactory) {
|
||||
this.playerFactory = playerFactory;
|
||||
this.apiClient = null;
|
||||
|
||||
this.timeSyncCore = new TimeSyncCore();
|
||||
this.playbackCore = new PlaybackCore();
|
||||
this.queueCore = new QueueCore();
|
||||
this.controller = new Controller();
|
||||
|
||||
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 = this.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 {PlaybackCore} The playback core.
|
||||
*/
|
||||
getPlaybackCore() {
|
||||
return this.playbackCore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the queue core.
|
||||
* @returns {QueueCore} The queue core.
|
||||
*/
|
||||
getQueueCore() {
|
||||
return this.queueCore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the controller used to manage SyncPlay playback.
|
||||
* @returns {Controller} 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 = this.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 = this.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]);
|
||||
}
|
||||
}
|
||||
|
||||
export default Manager;
|
588
src/components/syncPlay/core/PlaybackCore.js
Normal file
588
src/components/syncPlay/core/PlaybackCore.js
Normal file
|
@ -0,0 +1,588 @@
|
|||
/**
|
||||
* 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 PlaybackCore {
|
||||
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 {Manager} 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 options = {
|
||||
When: now.toISOString(),
|
||||
PositionTicks: currentPositionTicks,
|
||||
IsPlaying: isPlaying,
|
||||
PlaylistItemId: playlistItemId
|
||||
};
|
||||
|
||||
const apiClient = this.manager.getApiClient();
|
||||
if (isBuffering) {
|
||||
apiClient.requestSyncPlayBuffering(options);
|
||||
} else {
|
||||
apiClient.requestSyncPlayReady(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Avoid spoilers by making sure that command item matches current playlist item.
|
||||
// This check is needed when switching from one item to another.
|
||||
const queueCore = this.manager.getQueueCore();
|
||||
const currentPlaylistItem = queueCore.getCurrentPlaylistItemId();
|
||||
if (lastCommand.PlaylistItemId !== currentPlaylistItem) 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 PlaybackCore;
|
371
src/components/syncPlay/core/QueueCore.js
Normal file
371
src/components/syncPlay/core/QueueCore.js
Normal file
|
@ -0,0 +1,371 @@
|
|||
/**
|
||||
* 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 QueueCore {
|
||||
constructor() {
|
||||
this.manager = null;
|
||||
this.lastPlayQueueUpdate = null;
|
||||
this.playlist = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the core.
|
||||
* @param {Manager} 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 'NextItem':
|
||||
case 'PreviousItem': {
|
||||
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.requestSyncPlayReady({
|
||||
When: now.toISOString(),
|
||||
PositionTicks: currentPositionTicks,
|
||||
IsPlaying: isPlaying,
|
||||
PlaylistItemId: this.getCurrentPlaylistItemId()
|
||||
});
|
||||
}).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 QueueCore;
|
16
src/components/syncPlay/core/index.js
Normal file
16
src/components/syncPlay/core/index.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as Helper from './Helper';
|
||||
import ManagerClass from './Manager';
|
||||
import PlayerFactoryClass from './players/PlayerFactory';
|
||||
import GenericPlayer from './players/GenericPlayer';
|
||||
|
||||
const PlayerFactory = new PlayerFactoryClass();
|
||||
const Manager = new ManagerClass(PlayerFactory);
|
||||
|
||||
export default {
|
||||
Helper,
|
||||
Manager,
|
||||
PlayerFactory,
|
||||
Players: {
|
||||
GenericPlayer
|
||||
}
|
||||
};
|
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 GenericPlayer {
|
||||
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.
|
||||
*/
|
||||
localNextItem() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks previous item in playlist.
|
||||
*/
|
||||
localPreviousItem() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 GenericPlayer;
|
71
src/components/syncPlay/core/players/PlayerFactory.js
Normal file
71
src/components/syncPlay/core/players/PlayerFactory.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Module that creates wrappers for known players.
|
||||
* @module components/syncPlay/core/players/PlayerFactory
|
||||
*/
|
||||
|
||||
import GenericPlayer from './GenericPlayer';
|
||||
|
||||
/**
|
||||
* Class that creates wrappers for known players.
|
||||
*/
|
||||
class PlayerFactory {
|
||||
constructor() {
|
||||
this.wrappers = {};
|
||||
this.DefaultWrapper = GenericPlayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a wrapper to the list of players that can be managed.
|
||||
* @param {GenericPlayer} 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 {GenericPlayer} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PlayerFactory;
|
|
@ -1,13 +1,12 @@
|
|||
/**
|
||||
* Module that manages time syncing with server.
|
||||
* @module components/syncPlay/timeSyncManager
|
||||
* Module that manages time syncing with another device.
|
||||
* @module components/syncPlay/core/timeSync/TimeSync
|
||||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import ServerConnections from '../ServerConnections';
|
||||
|
||||
/**
|
||||
* Time estimation
|
||||
* Time estimation.
|
||||
*/
|
||||
const NumberOfTrackedMeasurements = 8;
|
||||
const PollingIntervalGreedy = 1000; // milliseconds
|
||||
|
@ -21,8 +20,8 @@ class Measurement {
|
|||
/**
|
||||
* Creates a new measurement.
|
||||
* @param {Date} requestSent Client's timestamp of the request transmission
|
||||
* @param {Date} requestReceived Server's timestamp of the request reception
|
||||
* @param {Date} responseSent Server's timestamp of the response transmission
|
||||
* @param {Date} requestReceived Remote's timestamp of the request reception
|
||||
* @param {Date} responseSent Remote's timestamp of the response transmission
|
||||
* @param {Date} responseReceived Client's timestamp of the response reception
|
||||
*/
|
||||
constructor(requestSent, requestReceived, responseSent, responseReceived) {
|
||||
|
@ -33,32 +32,33 @@ class Measurement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Time offset from server.
|
||||
* Time offset from remote entity, in milliseconds.
|
||||
*/
|
||||
getOffset () {
|
||||
getOffset() {
|
||||
return ((this.requestReceived - this.requestSent) + (this.responseSent - this.responseReceived)) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get round-trip delay.
|
||||
* Get round-trip delay, in milliseconds.
|
||||
*/
|
||||
getDelay () {
|
||||
getDelay() {
|
||||
return (this.responseReceived - this.requestSent) - (this.responseSent - this.requestReceived);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ping time.
|
||||
* Get ping time, in milliseconds.
|
||||
*/
|
||||
getPing () {
|
||||
getPing() {
|
||||
return this.getDelay() / 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that manages time syncing with server.
|
||||
* Class that manages time syncing with remote entity.
|
||||
*/
|
||||
class TimeSyncManager {
|
||||
constructor() {
|
||||
class TimeSync {
|
||||
constructor(syncPlayManager) {
|
||||
this.manager = syncPlayManager;
|
||||
this.pingStop = true;
|
||||
this.pollingInterval = PollingIntervalGreedy;
|
||||
this.poller = null;
|
||||
|
@ -76,23 +76,23 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets time offset with server.
|
||||
* Gets time offset with remote entity, in milliseconds.
|
||||
* @returns {number} The time offset.
|
||||
*/
|
||||
getTimeOffset () {
|
||||
getTimeOffset() {
|
||||
return this.measurement ? this.measurement.getOffset() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets ping time to server.
|
||||
* Gets ping time to remote entity, in milliseconds.
|
||||
* @returns {number} The ping time.
|
||||
*/
|
||||
getPing () {
|
||||
getPing() {
|
||||
return this.measurement ? this.measurement.getPing() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates time offset between server and client.
|
||||
* Updates time offset between remote entity and local entity.
|
||||
* @param {Measurement} measurement The new measurement.
|
||||
*/
|
||||
updateTimeOffset(measurement) {
|
||||
|
@ -101,31 +101,46 @@ class TimeSyncManager {
|
|||
this.measurements.shift();
|
||||
}
|
||||
|
||||
// Pick measurement with minimum delay
|
||||
// Pick measurement with minimum delay.
|
||||
const sortedMeasurements = this.measurements.slice(0);
|
||||
sortedMeasurements.sort((a, b) => a.getDelay() - b.getDelay());
|
||||
this.measurement = sortedMeasurements[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a ping request to the server. Triggers time offset update.
|
||||
* Schedules a ping request to the remote entity. Triggers time offset update.
|
||||
* @returns {Promise} Resolves on request success.
|
||||
*/
|
||||
requestPing() {
|
||||
if (!this.poller) {
|
||||
console.warn('SyncPlay TimeSync requestPing: override this method!');
|
||||
return Promise.reject('Not implemented.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Poller for ping requests.
|
||||
*/
|
||||
internalRequestPing() {
|
||||
if (!this.poller && !this.pingStop) {
|
||||
this.poller = setTimeout(() => {
|
||||
this.poller = null;
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
const requestSent = new Date();
|
||||
apiClient.getServerTime().then((response) => {
|
||||
const responseReceived = new Date();
|
||||
response.json().then((data) => {
|
||||
const requestReceived = new Date(data.RequestReceptionTime);
|
||||
const responseSent = new Date(data.ResponseTransmissionTime);
|
||||
this.requestPing()
|
||||
.then((result) => this.onPingResponseCallback(result))
|
||||
.catch((error) => this.onPingRequestErrorCallback(error))
|
||||
.finally(() => this.internalRequestPing());
|
||||
}, this.pollingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a successful ping request.
|
||||
* @param {Object} result The ping result.
|
||||
*/
|
||||
onPingResponseCallback(result) {
|
||||
const { requestSent, requestReceived, responseSent, responseReceived } = result;
|
||||
const measurement = new Measurement(requestSent, requestReceived, responseSent, responseReceived);
|
||||
this.updateTimeOffset(measurement);
|
||||
|
||||
// Avoid overloading server
|
||||
// Avoid overloading network.
|
||||
if (this.pings >= GreedyPingCount) {
|
||||
this.pollingInterval = PollingIntervalLowProfile;
|
||||
} else {
|
||||
|
@ -133,21 +148,21 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
Events.trigger(this, 'update', [null, this.getTimeOffset(), this.getPing()]);
|
||||
});
|
||||
}).catch((error) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a failed ping request.
|
||||
* @param {Object} error The error.
|
||||
*/
|
||||
onPingRequestErrorCallback(error) {
|
||||
console.error(error);
|
||||
Events.trigger(this, 'update', [error, null, null]);
|
||||
}).finally(() => {
|
||||
this.requestPing();
|
||||
});
|
||||
}, this.pollingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops accumulated measurements.
|
||||
*/
|
||||
resetMeasurements () {
|
||||
resetMeasurements() {
|
||||
this.measurement = null;
|
||||
this.measurements = [];
|
||||
}
|
||||
|
@ -156,13 +171,15 @@ class TimeSyncManager {
|
|||
* Starts the time poller.
|
||||
*/
|
||||
startPing() {
|
||||
this.requestPing();
|
||||
this.pingStop = false;
|
||||
this.internalRequestPing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the time poller.
|
||||
*/
|
||||
stopPing() {
|
||||
this.pingStop = true;
|
||||
if (this.poller) {
|
||||
clearTimeout(this.poller);
|
||||
this.poller = null;
|
||||
|
@ -180,25 +197,24 @@ class TimeSyncManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Converts server time to local time.
|
||||
* @param {Date} server The time to convert.
|
||||
* Converts remote time to local time.
|
||||
* @param {Date} remote The time to convert.
|
||||
* @returns {Date} Local time.
|
||||
*/
|
||||
serverDateToLocal(server) {
|
||||
// server - local = offset
|
||||
return new Date(server.getTime() - this.getTimeOffset());
|
||||
remoteDateToLocal(remote) {
|
||||
// remote - local = offset
|
||||
return new Date(remote.getTime() - this.getTimeOffset());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts local time to server time.
|
||||
* Converts local time to remote time.
|
||||
* @param {Date} local The time to convert.
|
||||
* @returns {Date} Server time.
|
||||
* @returns {Date} Remote time.
|
||||
*/
|
||||
localDateToServer(local) {
|
||||
// server - local = offset
|
||||
localDateToRemote(local) {
|
||||
// remote - local = offset
|
||||
return new Date(local.getTime() + this.getTimeOffset());
|
||||
}
|
||||
}
|
||||
|
||||
/** TimeSyncManager singleton. */
|
||||
export default new TimeSyncManager();
|
||||
export default TimeSync;
|
78
src/components/syncPlay/core/timeSync/TimeSyncCore.js
Normal file
78
src/components/syncPlay/core/timeSync/TimeSyncCore.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Module that manages time syncing with several devices.
|
||||
* @module components/syncPlay/core/timeSync/TimeSyncCore
|
||||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import TimeSyncServer from './TimeSyncServer';
|
||||
|
||||
/**
|
||||
* 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/TimeSyncServer.js
Normal file
39
src/components/syncPlay/core/timeSync/TimeSyncServer.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Module that manages time syncing with server.
|
||||
* @module components/syncPlay/core/timeSync/TimeSyncServer
|
||||
*/
|
||||
|
||||
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,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 '../core';
|
||||
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/ui/players/HtmlAudioPlayer
|
||||
*/
|
||||
|
||||
import HtmlVideoPlayer from './HtmlVideoPlayer';
|
||||
|
||||
/**
|
||||
* Class that manages the HtmlAudioPlayer for SyncPlay.
|
||||
*/
|
||||
class HtmlAudioPlayer extends HtmlVideoPlayer {
|
||||
static type = 'htmlaudioplayer';
|
||||
|
||||
constructor(player, syncPlayManager) {
|
||||
super(player, syncPlayManager);
|
||||
}
|
||||
}
|
||||
|
||||
export default HtmlAudioPlayer;
|
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/ui/players/HtmlVideoPlayer
|
||||
*/
|
||||
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import NoActivePlayer from './NoActivePlayer';
|
||||
|
||||
/**
|
||||
* Class that manages the HtmlVideoPlayer for SyncPlay.
|
||||
*/
|
||||
class HtmlVideoPlayer extends NoActivePlayer {
|
||||
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 HtmlVideoPlayer;
|
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/ui/players/NoActivePlayer
|
||||
*/
|
||||
|
||||
import { playbackManager } from '../../../playback/playbackmanager';
|
||||
import SyncPlay from '../../core';
|
||||
import QueueManager from './QueueManager';
|
||||
|
||||
let syncPlayManager;
|
||||
|
||||
/**
|
||||
* Class that manages the PlaybackManager when there's no active player.
|
||||
*/
|
||||
class NoActivePlayer 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(command, player) {
|
||||
console.debug('SyncPlay sendCommand:', command.Name, command);
|
||||
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[command.Name];
|
||||
if (typeof commandHandler === 'function') {
|
||||
commandHandler(command, player);
|
||||
} else {
|
||||
defaultAction(command, 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.nextItem();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides PlaybackManager's previousTrack method.
|
||||
*/
|
||||
previousTrackRequest(player) {
|
||||
const controller = syncPlayManager.getController();
|
||||
controller.previousItem();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
localNextItem() {
|
||||
if (playbackManager.syncPlayEnabled) {
|
||||
playbackManager._localNextTrack(this.player);
|
||||
} else {
|
||||
playbackManager.nextTrack(this.player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls original PlaybackManager's previousTrack method.
|
||||
*/
|
||||
localPreviousItem() {
|
||||
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 NoActivePlayer;
|
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/ui/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 '../core';
|
||||
|
||||
/**
|
||||
* 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;
|
|
@ -79,10 +79,10 @@ import confirm from '../../../components/confirm/confirm';
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'dlnasettings.html',
|
||||
href: '#!/dlnasettings.html',
|
||||
name: globalize.translate('Settings')
|
||||
}, {
|
||||
href: 'dlnaprofiles.html',
|
||||
href: '#!/dlnaprofiles.html',
|
||||
name: globalize.translate('TabProfiles')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -38,10 +38,10 @@ import Dashboard from '../../../scripts/clientUtils';
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'dlnasettings.html',
|
||||
href: '#!/dlnasettings.html',
|
||||
name: globalize.translate('Settings')
|
||||
}, {
|
||||
href: 'dlnaprofiles.html',
|
||||
href: '#!/dlnaprofiles.html',
|
||||
name: globalize.translate('TabProfiles')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -359,16 +359,16 @@ import confirm from '../../components/confirm/confirm';
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'library.html',
|
||||
href: '#!/library.html',
|
||||
name: globalize.translate('HeaderLibraries')
|
||||
}, {
|
||||
href: 'librarydisplay.html',
|
||||
href: '#!/librarydisplay.html',
|
||||
name: globalize.translate('Display')
|
||||
}, {
|
||||
href: 'metadataimages.html',
|
||||
href: '#!/metadataimages.html',
|
||||
name: globalize.translate('Metadata')
|
||||
}, {
|
||||
href: 'metadatanfo.html',
|
||||
href: '#!/metadatanfo.html',
|
||||
name: globalize.translate('TabNfoSettings')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -9,16 +9,16 @@ import Dashboard from '../../scripts/clientUtils';
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'library.html',
|
||||
href: '#!/library.html',
|
||||
name: globalize.translate('HeaderLibraries')
|
||||
}, {
|
||||
href: 'librarydisplay.html',
|
||||
href: '#!/librarydisplay.html',
|
||||
name: globalize.translate('Display')
|
||||
}, {
|
||||
href: 'metadataimages.html',
|
||||
href: '#!/metadataimages.html',
|
||||
name: globalize.translate('Metadata')
|
||||
}, {
|
||||
href: 'metadatanfo.html',
|
||||
href: '#!/metadatanfo.html',
|
||||
name: globalize.translate('TabNfoSettings')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -52,16 +52,16 @@ import Dashboard from '../../scripts/clientUtils';
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'library.html',
|
||||
href: '#!/library.html',
|
||||
name: globalize.translate('HeaderLibraries')
|
||||
}, {
|
||||
href: 'librarydisplay.html',
|
||||
href: '#!/librarydisplay.html',
|
||||
name: globalize.translate('Display')
|
||||
}, {
|
||||
href: 'metadataimages.html',
|
||||
href: '#!/metadataimages.html',
|
||||
name: globalize.translate('Metadata')
|
||||
}, {
|
||||
href: 'metadatanfo.html',
|
||||
href: '#!/metadatanfo.html',
|
||||
name: globalize.translate('TabNfoSettings')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -47,16 +47,16 @@ import alert from '../../components/alert';
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'library.html',
|
||||
href: '#!/library.html',
|
||||
name: globalize.translate('HeaderLibraries')
|
||||
}, {
|
||||
href: 'librarydisplay.html',
|
||||
href: '#!/librarydisplay.html',
|
||||
name: globalize.translate('Display')
|
||||
}, {
|
||||
href: 'metadataimages.html',
|
||||
href: '#!/metadataimages.html',
|
||||
name: globalize.translate('Metadata')
|
||||
}, {
|
||||
href: 'metadatanfo.html',
|
||||
href: '#!/metadatanfo.html',
|
||||
name: globalize.translate('TabNfoSettings')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -136,7 +136,7 @@ import alert from '../../components/alert';
|
|||
page.querySelector('#txtPortNumber').value = config.HttpServerPortNumber;
|
||||
page.querySelector('#txtPublicPort').value = config.PublicPort;
|
||||
page.querySelector('#txtPublicHttpsPort').value = config.PublicHttpsPort;
|
||||
page.querySelector('#txtLocalAddress').value = (config.LocalNetworkSubnets || []).join(', ');
|
||||
page.querySelector('#txtLocalAddress').value = (config.LocalNetworkAddresses || []).join(', ');
|
||||
page.querySelector('#txtLanNetworks').value = (config.LocalNetworkSubnets || []).join(', ');
|
||||
page.querySelector('#txtKnownProxies').value = (config.KnownProxies || []).join(', ');
|
||||
page.querySelector('#txtExternalAddressFilter').value = (config.RemoteIPFilter || []).join(', ');
|
||||
|
|
|
@ -123,13 +123,13 @@ function getPluginHtml(plugin, options, installedPlugins) {
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'installedplugins.html',
|
||||
href: '#!/installedplugins.html',
|
||||
name: globalize.translate('TabMyPlugins')
|
||||
}, {
|
||||
href: 'availableplugins.html',
|
||||
href: '#!/availableplugins.html',
|
||||
name: globalize.translate('TabCatalog')
|
||||
}, {
|
||||
href: 'repositories.html',
|
||||
href: '#!/repositories.html',
|
||||
name: globalize.translate('TabRepositories')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -159,13 +159,13 @@ function reloadList(page) {
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'installedplugins.html',
|
||||
href: '#!/installedplugins.html',
|
||||
name: globalize.translate('TabMyPlugins')
|
||||
}, {
|
||||
href: 'availableplugins.html',
|
||||
href: '#!/availableplugins.html',
|
||||
name: globalize.translate('TabCatalog')
|
||||
}, {
|
||||
href: 'repositories.html',
|
||||
href: '#!/repositories.html',
|
||||
name: globalize.translate('TabRepositories')
|
||||
}];
|
||||
}
|
||||
|
|
|
@ -77,13 +77,13 @@ function getRepositoryHtml(repository) {
|
|||
|
||||
function getTabs() {
|
||||
return [{
|
||||
href: 'installedplugins.html',
|
||||
href: '#!/installedplugins.html',
|
||||
name: globalize.translate('TabMyPlugins')
|
||||
}, {
|
||||
href: 'availableplugins.html',
|
||||
href: '#!/availableplugins.html',
|
||||
name: globalize.translate('TabCatalog')
|
||||
}, {
|
||||
href: 'repositories.html',
|
||||
href: '#!/repositories.html',
|
||||
name: globalize.translate('TabRepositories')
|
||||
}];
|
||||
}
|
||||
|
@ -136,7 +136,9 @@ export default function(view, params) {
|
|||
dialogHelper.close(dialog);
|
||||
});
|
||||
|
||||
dialog.querySelector('.newPluginForm').addEventListener('submit', () => {
|
||||
dialog.querySelector('.newPluginForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
|
||||
repositories.push({
|
||||
Name: dialog.querySelector('#txtRepositoryName').value,
|
||||
Url: dialog.querySelector('#txtRepositoryUrl').value,
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
<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="videoOsdBottom videoOsdBottom-maincontrols">
|
||||
<div class="osdControls">
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { playbackManager } from '../../../components/playback/playbackmanager';
|
||||
import SyncPlay from '../../../components/syncPlay/core';
|
||||
import dom from '../../../scripts/dom';
|
||||
import inputManager from '../../../scripts/inputManager';
|
||||
import mouseManager from '../../../scripts/mouseManager';
|
||||
|
@ -1569,6 +1570,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 */
|
||||
|
|
|
@ -29,17 +29,11 @@ export default function (view, params) {
|
|||
page.querySelector('.lnkSubtitlePreferences').setAttribute('href', '#!/mypreferencessubtitles.html?userId=' + userId);
|
||||
page.querySelector('.lnkQuickConnectPreferences').setAttribute('href', '#!/mypreferencesquickconnect.html');
|
||||
|
||||
if (window.NativeShell && window.NativeShell.AppHost.supports('clientsettings')) {
|
||||
page.querySelector('.clientSettings').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.clientSettings').classList.add('hide');
|
||||
}
|
||||
const supportsClientSettings = appHost.supports('clientsettings');
|
||||
page.querySelector('.clientSettings').classList.toggle('hide', !supportsClientSettings);
|
||||
|
||||
if (appHost.supports('multiserver')) {
|
||||
page.querySelector('.selectServer').classList.remove('hide');
|
||||
} else {
|
||||
page.querySelector('.selectServer').classList.add('hide');
|
||||
}
|
||||
const supportsMultiServer = appHost.supports('multiserver');
|
||||
page.querySelector('.selectServer').classList.toggle('hide', !supportsMultiServer);
|
||||
|
||||
ApiClient.getUser(userId).then(function (user) {
|
||||
page.querySelector('.headerUsername').innerHTML = user.Name;
|
||||
|
|
|
@ -18,7 +18,7 @@ function onAnchorClick(e) {
|
|||
shell.openUrl(href);
|
||||
}
|
||||
} else {
|
||||
appRouter.handleAnchorClick(e);
|
||||
appRouter.show(href);
|
||||
}
|
||||
} else {
|
||||
e.preventDefault();
|
||||
|
|
|
@ -6,8 +6,8 @@ import viewManager from '../components/viewManager/viewManager';
|
|||
import { appRouter } from '../components/appRouter';
|
||||
import { appHost } from '../components/apphost';
|
||||
import { playbackManager } from '../components/playback/playbackmanager';
|
||||
import syncPlayManager from '../components/syncPlay/syncPlayManager';
|
||||
import { show as groupSelectionMenuShow } from '../components/syncPlay/groupSelectionMenu';
|
||||
import SyncPlay from '../components/syncPlay/core';
|
||||
import groupSelectionMenu from '../components/syncPlay/ui/groupSelectionMenu';
|
||||
import browser from './browser';
|
||||
import globalize from './globalize';
|
||||
import imageHelper from './imagehelper';
|
||||
|
@ -230,7 +230,7 @@ import Headroom from 'headroom.js';
|
|||
|
||||
function onSyncButtonClicked() {
|
||||
const btn = this;
|
||||
groupSelectionMenuShow(btn);
|
||||
groupSelectionMenu.show(btn);
|
||||
}
|
||||
|
||||
function onSyncPlayEnabled(event, enabled) {
|
||||
|
@ -315,7 +315,7 @@ import Headroom from 'headroom.js';
|
|||
html += '</h3>';
|
||||
|
||||
if (appHost.supports('multiserver')) {
|
||||
html += '<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder" data-itemid="selectserver" href="#!/selectserver.html?showuser=1"><span class="material-icons navMenuOptionIcon wifi"></span><span class="navMenuOptionText">' + globalize.translate('SelectServer') + '</span></a>';
|
||||
html += '<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder btnSelectServer" data-itemid="selectserver" href="#"><span class="material-icons navMenuOptionIcon wifi"></span><span class="navMenuOptionText">' + globalize.translate('SelectServer') + '</span></a>';
|
||||
}
|
||||
|
||||
html += '<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder btnSettings" data-itemid="settings" href="#"><span class="material-icons navMenuOptionIcon settings"></span><span class="navMenuOptionText">' + globalize.translate('Settings') + '</span></a>';
|
||||
|
@ -326,6 +326,11 @@ import Headroom from 'headroom.js';
|
|||
// add buttons to navigation drawer
|
||||
navDrawerScrollContainer.innerHTML = html;
|
||||
|
||||
const btnSelectServer = navDrawerScrollContainer.querySelector('.btnSelectServer');
|
||||
if (btnSelectServer) {
|
||||
btnSelectServer.addEventListener('click', onSelectServerClick);
|
||||
}
|
||||
|
||||
const btnSettings = navDrawerScrollContainer.querySelector('.btnSettings');
|
||||
if (btnSettings) {
|
||||
btnSettings.addEventListener('click', onSettingsClick);
|
||||
|
@ -598,7 +603,7 @@ import Headroom from 'headroom.js';
|
|||
guideView.Name = globalize.translate('Guide');
|
||||
guideView.ImageTags = {};
|
||||
guideView.icon = 'dvr';
|
||||
guideView.url = 'livetv.html?tab=1';
|
||||
guideView.url = '#!/livetv.html?tab=1';
|
||||
list.push(guideView);
|
||||
}
|
||||
}
|
||||
|
@ -677,6 +682,10 @@ import Headroom from 'headroom.js';
|
|||
}
|
||||
}
|
||||
|
||||
function onSelectServerClick() {
|
||||
Dashboard.selectServer();
|
||||
}
|
||||
|
||||
function onSettingsClick() {
|
||||
Dashboard.navigate('mypreferencesmenu.html');
|
||||
}
|
||||
|
@ -1000,8 +1009,8 @@ import Headroom from 'headroom.js';
|
|||
|
||||
Events.on(playbackManager, 'playerchange', updateCastIcon);
|
||||
|
||||
Events.on(syncPlayManager, 'enabled', onSyncPlayEnabled);
|
||||
Events.on(syncPlayManager, 'syncing', onSyncPlaySyncing);
|
||||
Events.on(SyncPlay.Manager, 'enabled', onSyncPlayEnabled);
|
||||
Events.on(SyncPlay.Manager, 'syncing', onSyncPlaySyncing);
|
||||
|
||||
loadNavDrawer();
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { playbackManager } from '../components/playback/playbackmanager';
|
||||
import syncPlayManager from '../components/syncPlay/syncPlayManager';
|
||||
import SyncPlay from '../components/syncPlay/core';
|
||||
import { Events } from 'jellyfin-apiclient';
|
||||
import inputManager from '../scripts/inputManager';
|
||||
import focusManager from '../components/focusManager';
|
||||
|
@ -194,9 +194,9 @@ function onMessageReceived(e, msg) {
|
|||
}
|
||||
}
|
||||
} else if (msg.MessageType === 'SyncPlayCommand') {
|
||||
syncPlayManager.processCommand(msg.Data, apiClient);
|
||||
SyncPlay.Manager.processCommand(msg.Data, apiClient);
|
||||
} else if (msg.MessageType === 'SyncPlayGroupUpdate') {
|
||||
syncPlayManager.processGroupUpdate(msg.Data, apiClient);
|
||||
SyncPlay.Manager.processGroupUpdate(msg.Data, apiClient);
|
||||
} else {
|
||||
Events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]);
|
||||
}
|
||||
|
|
|
@ -32,6 +32,12 @@ import './serverNotifications';
|
|||
import '../components/playback/playerSelectionMenu';
|
||||
import '../legacy/focusPreventScroll';
|
||||
import '../legacy/vendorStyles';
|
||||
import SyncPlay from '../components/syncPlay/core';
|
||||
import { playbackManager } from '../components/playback/playbackmanager';
|
||||
import SyncPlayToasts from '../components/syncPlay/ui/syncPlayToasts';
|
||||
import SyncPlayNoActivePlayer from '../components/syncPlay/ui/players/NoActivePlayer';
|
||||
import SyncPlayHtmlVideoPlayer from '../components/syncPlay/ui/players/HtmlVideoPlayer';
|
||||
import SyncPlayHtmlAudioPlayer from '../components/syncPlay/ui/players/HtmlAudioPlayer';
|
||||
|
||||
// TODO: Move this elsewhere
|
||||
window.getWindowLocationSearch = function(win) {
|
||||
|
@ -116,6 +122,7 @@ function onGlobalizeInit() {
|
|||
import('../assets/css/librarybrowser.css');
|
||||
|
||||
loadPlugins().then(function () {
|
||||
initSyncPlay();
|
||||
onAppReady();
|
||||
});
|
||||
}
|
||||
|
@ -153,6 +160,23 @@ function loadPlugins() {
|
|||
});
|
||||
}
|
||||
|
||||
function initSyncPlay() {
|
||||
// Register player wrappers.
|
||||
SyncPlay.PlayerFactory.setDefaultWrapper(SyncPlayNoActivePlayer);
|
||||
SyncPlay.PlayerFactory.registerWrapper(SyncPlayHtmlVideoPlayer);
|
||||
SyncPlay.PlayerFactory.registerWrapper(SyncPlayHtmlAudioPlayer);
|
||||
|
||||
// Listen for player changes.
|
||||
Events.on(playbackManager, 'playerchange', (event, newPlayer, newTarget, oldPlayer) => {
|
||||
SyncPlay.Manager.onPlayerChange(newPlayer, newTarget, oldPlayer);
|
||||
});
|
||||
|
||||
// Start SyncPlay.
|
||||
const apiClient = ServerConnections.currentApiClient();
|
||||
SyncPlay.Manager.init(apiClient);
|
||||
SyncPlayToasts.init();
|
||||
}
|
||||
|
||||
function onAppReady() {
|
||||
console.debug('begin onAppReady');
|
||||
|
||||
|
|
|
@ -838,13 +838,18 @@
|
|||
"LabelSyncPlayAccessCreateAndJoinGroups": "Allow user to create and join groups",
|
||||
"LabelSyncPlayAccessJoinGroups": "Allow user to join groups",
|
||||
"LabelSyncPlayAccessNone": "Disabled for this user",
|
||||
"LabelSyncPlayHaltPlayback": "Stop local playback",
|
||||
"LabelSyncPlayHaltPlaybackDescription": "And ignore current playlist updates",
|
||||
"LabelSyncPlayLeaveGroup": "Leave group",
|
||||
"LabelSyncPlayLeaveGroupDescription": "Disable SyncPlay",
|
||||
"LabelSyncPlayNewGroup": "New group",
|
||||
"LabelSyncPlayNewGroupDescription": "Create a new group",
|
||||
"LabelSyncPlayPlaybackDiff": "Playback time difference:",
|
||||
"LabelSyncPlayResumePlayback": "Resume local playback",
|
||||
"LabelSyncPlayResumePlaybackDescription": "Join back group playback",
|
||||
"LabelSyncPlaySyncMethod": "Sync method:",
|
||||
"LabelSyncPlayTimeOffset": "Time offset with the server:",
|
||||
"LabelSyncPlayTimeSyncDevice": "Time syncing with:",
|
||||
"LabelSyncPlayTimeSyncOffset": "Time offset:",
|
||||
"LabelTag": "Tag:",
|
||||
"LabelTagline": "Tagline:",
|
||||
"LabelTextBackgroundColor": "Text background color:",
|
||||
|
@ -1019,7 +1024,8 @@
|
|||
"MessageSyncPlayErrorNoActivePlayer": "No active player found. SyncPlay has been disabled.",
|
||||
"MessageSyncPlayGroupDoesNotExist": "Failed to join group because it does not exist.",
|
||||
"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.",
|
||||
"MessageSyncPlayNoGroupsAvailable": "No groups available. Start playing something first.",
|
||||
"MessageSyncPlayPlaybackPermissionRequired": "Playback permission required.",
|
||||
|
@ -1348,6 +1354,7 @@
|
|||
"Sunday": "Sunday",
|
||||
"Sync": "Sync",
|
||||
"SyncPlayAccessHelp": "Select the level of access this user has to the SyncPlay feature. SyncPlay enables to sync playback with other devices.",
|
||||
"SyncPlayGroupDefaultTitle": "{0}'s group",
|
||||
"SystemDlnaProfilesHelp": "System profiles are read-only. Changes to a system profile will be saved to a new custom profile.",
|
||||
"TabAccess": "Access",
|
||||
"TabAdvanced": "Advanced",
|
||||
|
|
|
@ -137,8 +137,8 @@
|
|||
"DetectingDevices": "Detectando dispositivos",
|
||||
"DeviceAccessHelp": "Esto solo se aplica a los dispositivos que pueden ser identificados de manera única y no impedirá el acceso desde navegadores. Filtrar el acceso a los dispositivos de los usuarios les impedirá usar nuevos dispositivos hasta que hayan sido aprobados aquí.",
|
||||
"DirectPlaying": "Reproducción directa",
|
||||
"DirectStreamHelp1": "El medio es compatible con el dispositivo en cuanto a la resolución y tipo de medio (H.264, AC3, etc.), pero está en un contenedor de archivo incompatible (mkv, avi, wmv, etc.). El video será reempaquetado en tiempo real antes de transmitirlo al dispositivo.",
|
||||
"DirectStreamHelp2": "Transmitir directamente un archivo usa muy poco poder de procesamiento con una pérdida mínima en la calidad de video.",
|
||||
"DirectStreamHelp1": "El flujo de video es compatible con el dispositivo, pero tiene un número de canales o formato de audio incompatible (DTS, TUEHD, etc). El flujo de video será reempaquetado en tiempo real antes de transmitirlo al dispositivo, codificando únicamente el flujo de audio.",
|
||||
"DirectStreamHelp2": "La cantidad de recursos consumida por el flujo directo generalmente depende del perfil de audio. Únicamente el flujo de video se matendrá intacto.",
|
||||
"DirectStreaming": "Transmisión directa",
|
||||
"Directors": "Directores",
|
||||
"Disc": "DIsco",
|
||||
|
@ -213,7 +213,7 @@
|
|||
"Guide": "Guía",
|
||||
"GuideProviderLogin": "Iniciar sesión",
|
||||
"GuideProviderSelectListings": "Elegir listados",
|
||||
"H264CrfHelp": "El «Factor de transferencia constante» (CRF) es la configuración de calidad por defecto para el codificador x264. Puedes establecer valores entre 0 y 51, donde los valores más bajos dan como resultado mejor calidad (a expensas de archivos más grandes). Los valores comunes son entre 18 y 28. El valor por defecto para x264 es 23, así que puedes usar este valor como punto de referencia.",
|
||||
"H264CrfHelp": "El «Factor de transferencia constante» (CRF) es la configuración de calidad por defecto para los codificadores x264 y x265. Puedes establecer valores entre 0 y 51, donde los valores más bajos dan como resultado mejor calidad (a expensas de archivos más grandes). Los valores comunes son entre 18 y 28. El valor por defecto para x264 es 23, para x265 es 28, así que puedes usar estos valores como punto de referencia.",
|
||||
"EncoderPresetHelp": "Elige un valor más rápido para mejorar el rendimiento, o uno más lento para mejorar la calidad.",
|
||||
"HDPrograms": "Programas en HD",
|
||||
"HardwareAccelerationWarning": "Habilitar la aceleración por hardware podría causar inestabilidad en algunos entornos. Asegúrate de que tu sistema operativo y controladores de video están actualizados. Si tienes dificultades reproduciendo videos después de habilitar esto, necesitarás volver a cambiar la configuración a Ninguno.",
|
||||
|
@ -516,7 +516,7 @@
|
|||
"LabelGroupMoviesIntoCollections": "Agrupar películas en colecciones",
|
||||
"LabelGroupMoviesIntoCollectionsHelp": "Cuando se muestran listados de películas, las películas dentro de una colección serán mostradas agrupadas en un solo artículo.",
|
||||
"LabelH264Crf": "CRF de codificación H264:",
|
||||
"LabelEncoderPreset": "Codificación H264 y H265 preestablecida:",
|
||||
"LabelEncoderPreset": "Codificación preestablecida:",
|
||||
"LabelHardwareAccelerationType": "Aceleración por hardware:",
|
||||
"LabelHardwareAccelerationTypeHelp": "La aceleración por hardware requiere configuración adicional.",
|
||||
"LabelHomeNetworkQuality": "Calidad en red local:",
|
||||
|
@ -574,7 +574,7 @@
|
|||
"LabelMetadataReaders": "Lectores de metadatos:",
|
||||
"LabelMetadataReadersHelp": "Ordena tus fuentes de metadatos locales por prioridad. El primer archivo encontrado será leído.",
|
||||
"LabelMetadataSavers": "Grabadores de metadatos:",
|
||||
"LabelMetadataSaversHelp": "Selecciona los formatos de archivo con los que se guardarán tus metadatos.",
|
||||
"LabelMetadataSaversHelp": "Selecciona los formatos de archivo que se usarán cuando se guarden tus metadatos.",
|
||||
"LabelMethod": "Método:",
|
||||
"LabelMinBackdropDownloadWidth": "Anchura mínima de descarga de imágenes de fondo:",
|
||||
"LabelMinResumeDuration": "Duración mínima para la reanudación:",
|
||||
|
@ -1425,5 +1425,35 @@
|
|||
"OptionAllowContentDownload": "Permitir descarga de contenido",
|
||||
"HeaderDeleteDevices": "Borrar todos los dispositivos",
|
||||
"DeleteDevicesConfirmation": "¿Seguro que deseas borrar todos los dispositivos? Todas las sesiones se terminaran. Los dispositivos reaparecerán la próxima vez que el usuario acceda.",
|
||||
"DeleteAll": "Borrar todo"
|
||||
"DeleteAll": "Borrar todo",
|
||||
"PluginFromRepo": "{0} del repositorio {1}",
|
||||
"LabelUDPPortRangeHelp": "Restringe Jellyfin a utilizar este rango de puertos cuando se realicen conexiones UDP. (El valor por defecto es 1024 - 645535) <br/> Nota: algunas funciones requieren el uso de puertos fijos que pueden estar fuera de este rango.",
|
||||
"LabelUDPPortRange": "Rango de Comunicación UDP:",
|
||||
"LabelSSDPTracingFilterHelp": "Dirección IP opcional que se utilizará como filtro para el registro del tráfico SSDP.",
|
||||
"LabelSSDPTracingFilter": "Filtro SSDP:",
|
||||
"LabelPublishedServerUriHelp": "Omite la URL utilizada por Jellyfin, basado en la interfaz o dirección IP del cliente.",
|
||||
"LabelPublishedServerUri": "URLs publicadas del servidor:",
|
||||
"LabelIsForced": "Forzado",
|
||||
"LabelHDHomerunPortRangeHelp": "Restringe el rango de puertos UDP para HD Homerun a este valor. (El valor por defecto es 1024 - 645535).",
|
||||
"LabelHDHomerunPortRange": "Rango de puertos HD Homerun:",
|
||||
"LabelH265Crf": "CRF de codificación H265:",
|
||||
"LabelEnableSSDPTracing": "Habilitar seguimiento SSDP:",
|
||||
"LabelEnableIP6Help": "Habilita la funcionalidad IPv6.",
|
||||
"LabelEnableIP6": "Habilitar IPv6:",
|
||||
"LabelEnableIP4Help": "Habilita la funcionalidad IPv4.",
|
||||
"LabelEnableIP4": "Habilitar IPv4:",
|
||||
"LabelDropSubtitleHere": "Arrastra el subtítulo aquí, o presiona en explorar.",
|
||||
"LabelCreateHttpPortMapHelp": "Permite crear una redirección de puertos automática con una regla para el tráfico http en adición al tráfico https.",
|
||||
"LabelCreateHttpPortMap": "Habilita la redirección de puertos automática para el tráfico http y https.",
|
||||
"LabelAutomaticDiscoveryHelp": "Permite a las aplicaciones detectar automáticamente esta instancia de Jellyfin utilizando el puerto UDP 7359.",
|
||||
"LabelAutomaticDiscovery": "Habilitar Descubrimiento Automático:",
|
||||
"LabelAutoDiscoveryTracingHelp": "Cuando está habilitado, se registrarán los pqquetes recibidos en el puerto de descubrimiento automático.",
|
||||
"LabelAutoDiscoveryTracing": "Habilitar seguimiento de Descubrimiento Automático.",
|
||||
"HeaderUploadSubtitle": "Subir Subtítulo",
|
||||
"HeaderPortRanges": "Configuración de Firewall y Proxy",
|
||||
"HeaderNetworking": "Protocolos IP",
|
||||
"HeaderDebugging": "Depuración y seguimiento",
|
||||
"HeaderAutoDiscovery": "Descubrimiento de Red",
|
||||
"HeaderAddUser": "Agregar Usuario",
|
||||
"HeaderAddUpdateSubtitle": "Agregar/Actualizar Subtítulo"
|
||||
}
|
||||
|
|
|
@ -1193,5 +1193,8 @@
|
|||
"LabelBindToLocalNetworkAddressHelp": "Korvaa HTTP-palvelimen paikallinen IP-osoite. Jos se on tyhjä, palvelin linkitetään kaikkiin käytettävissä oleviin osoitteisiin. Tämän arvon muuttaminen edellyttää uudelleenkäynnistystä.",
|
||||
"LabelDefaultUserHelp": "Määrittää käyttäjän kirjaston, joka näytetään liitetyissä laitteissa. Tätä asetusta voidaan muuttaa laitekohtaisesti laiteprofiilin avulla.",
|
||||
"ClearQueue": "Tyhjennä jono",
|
||||
"ButtonPlayer": "Soitin"
|
||||
"ButtonPlayer": "Soitin",
|
||||
"DeleteDevicesConfirmation": "Haluatko varmasti poistaa kaikki laitteet? Kaikki muut istunnot tullaan kirjaamaan ulos. Laitteet näytetään uudelleen kun käyttäjä kirjautuu seuraavan kerran sisään.",
|
||||
"DeleteAll": "Poista kaikki",
|
||||
"ButtonCast": "Näyttelijät"
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
"WelcomeToProject": "Bienvenue dans Jellyfin !",
|
||||
"WizardCompleted": "C'est tout ce dont nous avons besoin pour le moment. Jellyfin a commencé à récolter les informations de votre bibliothèque de médias. Jetez un oeil à quelques unes de nos applications, puis cliquez sur <b>Terminer</b> pour consulter le <b>Tableau de bord</b>.",
|
||||
"Absolute": "Absolu",
|
||||
"AccessRestrictedTryAgainLater": "Accès restreint. Veuillez réessayer plus tard.",
|
||||
"AccessRestrictedTryAgainLater": "L'accès est présentement restreint. Veuillez réessayer plus tard.",
|
||||
"Actor": "Acteur(trice)",
|
||||
"AddToPlayQueue": "Ajouter à la file d'attente",
|
||||
"AddedOnValue": "Ajouté le {0}",
|
||||
|
@ -88,7 +88,7 @@
|
|||
"AllowMediaConversion": "Autoriser la conversion des médias",
|
||||
"AllowMediaConversionHelp": "Autoriser ou refuser l'accès à la fonctionnalité de conversion des médias.",
|
||||
"AllowOnTheFlySubtitleExtraction": "Autoriser l'extraction des sous-titres à la volée",
|
||||
"AllowOnTheFlySubtitleExtractionHelp": "Les sous-titres intégrés peuvent être extraits des vidéos et distribués aux clients en format texte pour éviter le transcodage. Sur certains systèmes, cela peut prendre du temps et arrêter la lecture de la vidéo pendant le processus d'extraction. Désactivez cette option pour graver les sous-titres avec un transcodage quand l'appareil ne les prend pas en charge nativement.",
|
||||
"AllowOnTheFlySubtitleExtractionHelp": "Les sous-titres intégrés peuvent être extraits des vidéos et distribués aux clients en texte brut pour éviter le transcodage. Sur certains systèmes, cela peut prendre du temps et arrêter la lecture de la vidéo pendant le processus d'extraction. Désactivez cette option pour graver les sous-titres avec un transcodage quand l'appareil ne les prend pas en charge nativement.",
|
||||
"AllowRemoteAccess": "Autoriser les connexions à distance sur ce serveur Jellyfin.",
|
||||
"AllowRemoteAccessHelp": "Si l'option est désactivée, toutes les connexions distantes seront bloquées.",
|
||||
"Artists": "Artistes",
|
||||
|
@ -152,11 +152,11 @@
|
|||
"Artist": "Artiste",
|
||||
"AllowFfmpegThrottlingHelp": "Quand un transcodage ou un remultiplexage a traité une période suffisamment longue depuis la position de lecture, le processus sera interrompu afin d'économiser des ressources. Ceci est utile principalement lors de lectures continues. À désactiver si vous éprouvez des problèmes de lecture.",
|
||||
"AllowFfmpegThrottling": "Limiter la vitesse de transcodage",
|
||||
"AlbumArtist": "Artiste de l'Album",
|
||||
"AlbumArtist": "Artiste de l'album",
|
||||
"Album": "Album",
|
||||
"AuthProviderHelp": "Sélectionner un fournisseur d'authentification pour authentifier le mot de passe de cet utilisateur.",
|
||||
"ButtonSyncPlay": "SyncPlay",
|
||||
"Default": "Défaut",
|
||||
"Default": "Par défaut",
|
||||
"DeathDateValue": "Mort: {0}",
|
||||
"DatePlayed": "Date écoutée",
|
||||
"DateAdded": "Date d'ajout",
|
||||
|
@ -348,5 +348,27 @@
|
|||
"DeleteAll": "Tout supprimer",
|
||||
"HeaderCodecProfile": "Profil de codec",
|
||||
"HeaderChapterImages": "Images des chapitres",
|
||||
"HeaderChannelAccess": "Accès aux chaînes"
|
||||
"HeaderChannelAccess": "Accès aux chaînes",
|
||||
"LatestFromLibrary": "{0}, ajouts récents",
|
||||
"HideWatchedContentFromLatestMedia": "Masquer le contenu déjà vu dans les derniers médias",
|
||||
"HeaderLatestRecordings": "Derniers enregistrements",
|
||||
"HeaderLatestMusic": "Dernière musique",
|
||||
"HeaderLatestMovies": "Derniers films",
|
||||
"HeaderLatestMedia": "Derniers médias",
|
||||
"HeaderLatestEpisodes": "Derniers épisodes",
|
||||
"LabelHomeScreenSectionValue": "Section {0} de l'accueil :",
|
||||
"LabelHomeNetworkQuality": "Qualité du réseau local :",
|
||||
"Home": "Accueuil",
|
||||
"DisplayInMyMedia": "Afficher sur l’écran d’accueil",
|
||||
"Display": "Affichage",
|
||||
"Disc": "Disque",
|
||||
"DirectStreaming": "Diffusion en continu directe",
|
||||
"DirectStreamHelp2": "La puissance utilisée par la diffusion en continue directe dépend en général du profil audio. Seul le flux vidéo est sans perte.",
|
||||
"DirectStreamHelp1": "Le flux vidéo est compatible avec l'appareil, mais utilise un format audio (DTS, TRUEHD, etc.) ou un nombre de canaux audio incompatibles. Le média va être rempaqueté à la volée avant d'être diffusé sur l'appareil. Seul le flux audio va être transcodé.",
|
||||
"DirectPlaying": "Lecture directe",
|
||||
"DeviceAccessHelp": "Ceci ne s'applique qu'aux appareils qui peuvent être identifiés de manière unique et n'empêchera pas l'accès par navigateur. Bloquer l'accès aux appareils par utilisateur empêchera l'utilisation de nouveaux appareils jusqu'à ce qu'ils soient approuvés ici.",
|
||||
"Descending": "Décroissant",
|
||||
"Depressed": "Diminuer",
|
||||
"DeleteDevicesConfirmation": "Voulez-vous vraiment supprimer tous les appareils ? Toutes les autres sessions seront déconnectées. Les appareils réapparaîtront la prochaine fois qu'un utilisateur se connectera.",
|
||||
"AllowTonemappingHelp": "Le mappage tonal peut transformer la gamme dynamique d'une vidéo de HDR à SDR tout en maintenant les détails et les couleurs d'image qui sont des informations importantes pour la représentation de la scène originale. Fonctionne uniquement lorsque des vidéos avec des métadonnées HDR10 ou HLG sont transcodées. Si la lecture n'est pas fluide ou échoue, considérez d'éteindre le décodeur matériel correspondant."
|
||||
}
|
||||
|
|
|
@ -1266,8 +1266,8 @@
|
|||
"ClientSettings": "Týtynýshy parametrleri",
|
||||
"ButtonTogglePlaylist": "Oýnatý tizimi",
|
||||
"BoxSet": "Jıyntyq",
|
||||
"Artist": "Ornatýshy",
|
||||
"AlbumArtist": "Álbom ornatýshysy",
|
||||
"Artist": "Oryndaýshy",
|
||||
"AlbumArtist": "Álbom oryndaýshysy",
|
||||
"MessageSyncPlayDisabled": "SyncPlay óshirýli.",
|
||||
"MessageSyncPlayEnabled": "SyncPlay qosýly.",
|
||||
"LabelSyncPlayAccess": "SyncPlay qatynasy",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue