mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Port to ES6
This commit is contained in:
parent
460c2a1f77
commit
56ee678fc2
5 changed files with 840 additions and 716 deletions
|
@ -105,7 +105,9 @@
|
|||
"src/scripts/keyboardnavigation.js",
|
||||
"src/scripts/settings/appSettings.js",
|
||||
"src/scripts/settings/userSettings.js",
|
||||
"src/scripts/settings/webSettings.js"
|
||||
"src/scripts/settings/webSettings.js",
|
||||
"src/components/syncplay/groupSelectionMenu.js",
|
||||
"src/components/syncplay/syncplayManager.js"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-modules-amd"
|
||||
|
|
|
@ -1,16 +1,34 @@
|
|||
define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayManager', 'globalize', 'datetime'], function (events, loading, connectionManager, playbackManager, syncplayManager, globalize, datetime) {
|
||||
'use strict';
|
||||
import events from 'events';
|
||||
import connectionManager from 'connectionManager';
|
||||
import playbackManager from 'playbackManager';
|
||||
import syncplayManager from 'syncplayManager';
|
||||
import loading from 'loading';
|
||||
import datetime from 'datetime';
|
||||
import toast from 'toast';
|
||||
import actionsheet from 'actionsheet';
|
||||
import globalize from 'globalize';
|
||||
|
||||
function getActivePlayerId() {
|
||||
/**
|
||||
* Gets active player id.
|
||||
* @returns {string} The player's id.
|
||||
*/
|
||||
function getActivePlayerId() {
|
||||
var info = playbackManager.getPlayerInfo();
|
||||
return info ? info.id : null;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyCallback() {
|
||||
/**
|
||||
* Used to avoid console logs about uncaught promises
|
||||
*/
|
||||
function emptyCallback() {
|
||||
// avoid console logs about uncaught promises
|
||||
}
|
||||
}
|
||||
|
||||
function showNewJoinGroupSelection(button) {
|
||||
/**
|
||||
* Used when user needs to join a group.
|
||||
* @param {HTMLElement} button - Element where to place the menu.
|
||||
*/
|
||||
function showNewJoinGroupSelection(button) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
sessionId = sessionId ? sessionId : "none";
|
||||
|
@ -46,20 +64,13 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa
|
|||
}
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
require(['toast'], function (alert) {
|
||||
alert({
|
||||
title: globalize.translate('MessageSyncplayNoGroupsAvailable'),
|
||||
toast({
|
||||
text: globalize.translate('MessageSyncplayNoGroupsAvailable')
|
||||
});
|
||||
});
|
||||
loading.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
require(['actionsheet'], function (actionsheet) {
|
||||
|
||||
loading.hide();
|
||||
|
||||
var menuOptions = {
|
||||
title: globalize.translate('HeaderSyncplaySelectGroup'),
|
||||
items: menuItems,
|
||||
|
@ -69,7 +80,6 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa
|
|||
};
|
||||
|
||||
actionsheet.show(menuOptions).then(function (id) {
|
||||
|
||||
if (id == "new-group") {
|
||||
apiClient.sendSyncplayCommand(sessionId, "NewGroup");
|
||||
} else {
|
||||
|
@ -78,21 +88,26 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa
|
|||
});
|
||||
}
|
||||
}, emptyCallback);
|
||||
});
|
||||
|
||||
loading.hide();
|
||||
});
|
||||
}).catch(function (error) {
|
||||
loading.hide();
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showLeaveGroupSelection(button) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
/**
|
||||
* Used when user has joined a group.
|
||||
* @param {HTMLElement} button - Element where to place the menu.
|
||||
*/
|
||||
function showLeaveGroupSelection(button) {
|
||||
const apiClient = connectionManager.currentApiClient();
|
||||
const sessionId = getActivePlayerId();
|
||||
|
||||
loading.show();
|
||||
|
||||
var menuItems = [{
|
||||
const menuItems = [{
|
||||
name: globalize.translate('LabelSyncplayLeaveGroup'),
|
||||
icon: "meeting_room",
|
||||
id: "leave-group",
|
||||
|
@ -100,10 +115,6 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa
|
|||
secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription')
|
||||
}];
|
||||
|
||||
require(['actionsheet'], function (actionsheet) {
|
||||
|
||||
loading.hide();
|
||||
|
||||
var menuOptions = {
|
||||
title: globalize.translate('HeaderSyncplayEnabled'),
|
||||
items: menuItems,
|
||||
|
@ -117,24 +128,24 @@ define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayMa
|
|||
apiClient.sendSyncplayCommand(sessionId, "LeaveGroup");
|
||||
}
|
||||
}, emptyCallback);
|
||||
});
|
||||
}
|
||||
|
||||
function showGroupSelection(button) {
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
// Register to Syncplay events
|
||||
let syncplayEnabled = false;
|
||||
events.on(syncplayManager, 'SyncplayEnabled', 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) {
|
||||
if (syncplayEnabled) {
|
||||
showLeaveGroupSelection(button);
|
||||
} else {
|
||||
showNewJoinGroupSelection(button);
|
||||
}
|
||||
}
|
||||
|
||||
var syncplayEnabled = false;
|
||||
|
||||
events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) {
|
||||
syncplayEnabled = enabled;
|
||||
});
|
||||
|
||||
return {
|
||||
show: showGroupSelection
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
704
src/components/syncplay/syncplayManager.js
Normal file
704
src/components/syncplay/syncplayManager.js
Normal file
|
@ -0,0 +1,704 @@
|
|||
/* eslint-disable indent */
|
||||
|
||||
/**
|
||||
* Module that manages the Syncplay feature.
|
||||
* @module components/syncplay/syncplayManager
|
||||
*/
|
||||
|
||||
import events from 'events';
|
||||
import connectionManager from 'connectionManager';
|
||||
import playbackManager from 'playbackManager';
|
||||
import toast from 'toast';
|
||||
import globalize from 'globalize';
|
||||
|
||||
/**
|
||||
* Waits for an event to be triggered on an object.
|
||||
* @param {Object} emitter Object on which to listen for events.
|
||||
* @param {string} eventType Event name to listen for.
|
||||
* @returns {Promise} A promise that resolves when the event is triggered.
|
||||
*/
|
||||
function waitForEvent(emitter, eventType) {
|
||||
return new Promise((resolve) => {
|
||||
var callback = () => {
|
||||
events.off(emitter, eventType, callback);
|
||||
resolve(arguments);
|
||||
};
|
||||
events.on(emitter, eventType, callback);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets active player id.
|
||||
* @returns {string} The player's id.
|
||||
*/
|
||||
function getActivePlayerId() {
|
||||
var info = playbackManager.getPlayerInfo();
|
||||
return info ? info.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback synchronization
|
||||
*/
|
||||
const MaxAcceptedDelaySpeedToSync = 50; // milliseconds, delay after which SpeedToSync is enabled
|
||||
const MaxAcceptedDelaySkipToSync = 300; // milliseconds, delay after which SkipToSync is enabled
|
||||
const SyncMethodThreshold = 2000; // milliseconds, switches between SpeedToSync or SkipToSync
|
||||
const SpeedUpToSyncTime = 1000; // milliseconds, duration in which the playback is sped up
|
||||
|
||||
/**
|
||||
* Time estimation
|
||||
*/
|
||||
const PingIntervalTimeoutGreedy = 1000; // milliseconds
|
||||
const PingIntervalTimeoutLowProfile = 60000; // milliseconds
|
||||
const GreedyPingCount = 3;
|
||||
|
||||
/**
|
||||
* 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.lastPlaybackWaiting = null; // used to determine if player's buffering
|
||||
this.minBufferingThresholdMillis = 1000;
|
||||
|
||||
this.currentPlayer = null;
|
||||
|
||||
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.pingStop = true;
|
||||
this.pingIntervalTimeout = PingIntervalTimeoutGreedy;
|
||||
this.pingInterval = null;
|
||||
this.initTimeDiff = 0; // number of pings
|
||||
this.timeDiff = 0; // local time minus server time
|
||||
this.roundTripDuration = 0;
|
||||
this.notifySyncplayReady = false;
|
||||
|
||||
events.on(playbackManager, "playerchange", () => {
|
||||
this.onPlayerChange();
|
||||
});
|
||||
this.bindToPlayer(playbackManager.getCurrentPlayer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the player changes.
|
||||
*/
|
||||
onPlayerChange () {
|
||||
this.bindToPlayer(playbackManager.getCurrentPlayer());
|
||||
events.trigger(this, "PlayerChange", [this.currentPlayer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on playback state changes.
|
||||
* @param {Object} e The playback state change event.
|
||||
*/
|
||||
onPlayPauseStateChanged (e) {
|
||||
events.trigger(this, "PlayPauseStateChange", [this.currentPlayer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on playback progress.
|
||||
* @param {Object} e The time update event.
|
||||
*/
|
||||
onTimeUpdate (e) {
|
||||
events.trigger(this, "TimeUpdate", [e]);
|
||||
|
||||
if (this.lastCommand && this.lastCommand.Command === 'Play' && !this.isBuffering()) {
|
||||
var currentTime = new Date();
|
||||
var playAtTime = this.lastCommand.When;
|
||||
|
||||
var state = playbackManager.getPlayerState().PlayState;
|
||||
// Estimate PositionTicks on server
|
||||
var ServerPositionTicks = this.lastCommand.PositionTicks + ((currentTime - playAtTime) - this.timeDiff) * 10000;
|
||||
// Measure delay that needs to be recovered
|
||||
// diff might be caused by the player internally starting the playback
|
||||
var diff = ServerPositionTicks - state.PositionTicks;
|
||||
var diffMillis = diff / 10000;
|
||||
|
||||
this.playbackDiffMillis = diffMillis;
|
||||
|
||||
// console.debug("Syncplay onTimeUpdate", diffMillis, state.PositionTicks, ServerPositionTicks);
|
||||
|
||||
if (this.syncEnabled) {
|
||||
var absDiffMillis = Math.abs(diffMillis);
|
||||
// TODO: SpeedToSync sounds bad on songs
|
||||
if (this.playbackRateSupported && absDiffMillis > MaxAcceptedDelaySpeedToSync && absDiffMillis < SyncMethodThreshold) {
|
||||
// SpeedToSync method
|
||||
var speed = 1 + diffMillis / SpeedUpToSyncTime;
|
||||
|
||||
this.currentPlayer.setPlaybackRate(speed);
|
||||
this.syncEnabled = false;
|
||||
this.showSyncIcon("SpeedToSync (x" + speed + ")");
|
||||
|
||||
this.syncTimeout = setTimeout(() => {
|
||||
this.currentPlayer.setPlaybackRate(1);
|
||||
this.syncEnabled = true;
|
||||
this.clearSyncIcon();
|
||||
}, SpeedUpToSyncTime);
|
||||
} else if (absDiffMillis > MaxAcceptedDelaySkipToSync) {
|
||||
// SkipToSync method
|
||||
playbackManager.syncplay_seek(ServerPositionTicks);
|
||||
this.syncEnabled = false;
|
||||
this.showSyncIcon("SkipToSync");
|
||||
|
||||
this.syncTimeout = setTimeout(() => {
|
||||
this.syncEnabled = true;
|
||||
this.clearSyncIcon();
|
||||
}, this.syncMethodThreshold / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when playback is resumed.
|
||||
*/
|
||||
onPlaying () {
|
||||
// TODO: implement group wait
|
||||
this.lastPlaybackWaiting = null;
|
||||
events.trigger(this, "PlayerPlaying");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when playback is buffering.
|
||||
*/
|
||||
onWaiting () {
|
||||
// TODO: implement group wait
|
||||
if (!this.lastPlaybackWaiting) {
|
||||
this.lastPlaybackWaiting = new Date();
|
||||
}
|
||||
events.trigger(this, "PlayerWaiting");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// TODO: remove this extra functions
|
||||
const self = this;
|
||||
this._onPlayPauseStateChanged = () => {
|
||||
self.onPlayPauseStateChanged();
|
||||
};
|
||||
|
||||
this._onPlayPauseStateChanged = (e) => {
|
||||
self.onPlayPauseStateChanged(e);
|
||||
};
|
||||
|
||||
this._onTimeUpdate = (e) => {
|
||||
self.onTimeUpdate(e);
|
||||
};
|
||||
|
||||
this._onPlaying = () => {
|
||||
self.onPlaying();
|
||||
};
|
||||
|
||||
this._onWaiting = () => {
|
||||
self.onWaiting();
|
||||
};
|
||||
|
||||
events.on(player, "pause", this._onPlayPauseStateChanged);
|
||||
events.on(player, "unpause", this._onPlayPauseStateChanged);
|
||||
events.on(player, "timeupdate", this._onTimeUpdate);
|
||||
events.on(player, "playing", this._onPlaying);
|
||||
events.on(player, "waiting", this._onWaiting);
|
||||
this.playbackRateSupported = player.supports("PlaybackRate");
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the bindings to the current player's events.
|
||||
*/
|
||||
releaseCurrentPlayer () {
|
||||
var player = this.currentPlayer;
|
||||
if (player) {
|
||||
events.off(player, "pause", this._onPlayPauseStateChanged);
|
||||
events.off(player, "unpause", this._onPlayPauseStateChanged);
|
||||
events.off(player, "timeupdate", this._onTimeUpdate);
|
||||
events.off(player, "playing", this._onPlaying);
|
||||
events.off(player, "waiting", this._onWaiting);
|
||||
if (this.playbackRateSupported) {
|
||||
player.setPlaybackRate(1);
|
||||
}
|
||||
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':
|
||||
var serverId = apiClient.serverInfo().Id;
|
||||
playbackManager.play({
|
||||
ids: cmd.Data.ItemIds,
|
||||
startPositionTicks: cmd.Data.StartPositionTicks,
|
||||
mediaSourceId: cmd.Data.MediaSourceId,
|
||||
audioStreamIndex: cmd.Data.AudioStreamIndex,
|
||||
subtitleStreamIndex: cmd.Data.SubtitleStreamIndex,
|
||||
startIndex: cmd.Data.StartIndex,
|
||||
serverId: serverId
|
||||
}).then(() => {
|
||||
waitForEvent(this, "PlayerChange").then(() => {
|
||||
playbackManager.pause();
|
||||
var sessionId = getActivePlayerId();
|
||||
if (!sessionId) {
|
||||
console.error("Missing sessionId!");
|
||||
toast({
|
||||
text: "Failed to enable Syncplay!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Sometimes JoinGroup fails, maybe because server hasn't been updated yet
|
||||
setTimeout(() => {
|
||||
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
|
||||
GroupId: cmd.GroupId
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
break;
|
||||
case 'UserJoined':
|
||||
toast({
|
||||
text: globalize.translate('MessageSyncplayUserJoined', cmd.Data)
|
||||
});
|
||||
break;
|
||||
case 'UserLeft':
|
||||
toast({
|
||||
text: globalize.translate('MessageSyncplayUserLeft', cmd.Data)
|
||||
});
|
||||
break;
|
||||
case 'GroupJoined':
|
||||
toast({
|
||||
text: globalize.translate('MessageSyncplayEnabled')
|
||||
});
|
||||
// Enable Syncplay
|
||||
this.syncplayEnabledAt = new Date(cmd.Data);
|
||||
this.syncplayReady = false;
|
||||
events.trigger(this, "SyncplayEnabled", [true]);
|
||||
waitForEvent(this, "SyncplayReady").then(() => {
|
||||
this.processCommand(this.queuedCommand, apiClient);
|
||||
this.queuedCommand = null;
|
||||
});
|
||||
this.injectPlaybackManager();
|
||||
this.startPing();
|
||||
break;
|
||||
case 'NotInGroup':
|
||||
case 'GroupLeft':
|
||||
toast({
|
||||
text: globalize.translate('MessageSyncplayDisabled')
|
||||
});
|
||||
// Disable Syncplay
|
||||
this.syncplayEnabledAt = null;
|
||||
this.syncplayReady = false;
|
||||
events.trigger(this, "SyncplayEnabled", [false]);
|
||||
this.restorePlaybackManager();
|
||||
this.stopPing();
|
||||
break;
|
||||
case 'GroupWait':
|
||||
toast({
|
||||
text: globalize.translate('MessageSyncplayGroupWait', cmd.Data)
|
||||
});
|
||||
break;
|
||||
case 'KeepAlive':
|
||||
break;
|
||||
default:
|
||||
console.error('processSyncplayGroupUpdate does not recognize: ' + 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: ignoring command", cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.syncplayReady) {
|
||||
console.debug("Syncplay processCommand: queued command", cmd);
|
||||
this.queuedCommand = cmd;
|
||||
return;
|
||||
}
|
||||
|
||||
cmd.When = new Date(cmd.When);
|
||||
|
||||
if (cmd.When < 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('processSyncplayCommand does not recognize: ' + cmd.Type);
|
||||
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.
|
||||
*/
|
||||
schedulePlay (playAtTime, positionTicks) {
|
||||
this.clearScheduledCommand();
|
||||
var currentTime = new Date();
|
||||
var playAtTimeLocal = this.serverDateToLocal(playAtTime);
|
||||
|
||||
if (playAtTimeLocal > currentTime) {
|
||||
var playTimeout = playAtTimeLocal - currentTime;
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
|
||||
this.scheduledCommand = setTimeout(() => {
|
||||
playbackManager.syncplay_unpause();
|
||||
|
||||
this.syncTimeout = setTimeout(() => {
|
||||
this.syncEnabled = true
|
||||
}, this.syncMethodThreshold / 2);
|
||||
|
||||
}, playTimeout);
|
||||
|
||||
// console.debug("Syncplay schedulePlay:", playTimeout);
|
||||
} else {
|
||||
// Group playback already started
|
||||
var serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000;
|
||||
playbackManager.syncplay_unpause();
|
||||
playbackManager.syncplay_seek(serverPositionTicks);
|
||||
|
||||
this.syncTimeout = setTimeout(() => {
|
||||
this.syncEnabled = true
|
||||
}, this.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();
|
||||
var currentTime = new Date();
|
||||
var pauseAtTimeLocal = this.serverDateToLocal(pauseAtTime);
|
||||
|
||||
if (pauseAtTimeLocal > currentTime) {
|
||||
var pauseTimeout = pauseAtTimeLocal - currentTime;
|
||||
|
||||
this.scheduledCommand = setTimeout(() => {
|
||||
playbackManager.syncplay_pause();
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
}, 800);
|
||||
|
||||
}, pauseTimeout);
|
||||
} else {
|
||||
playbackManager.syncplay_pause();
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.syncplay_unpause = playbackManager.unpause;
|
||||
playbackManager.syncplay_pause = playbackManager.pause;
|
||||
playbackManager.syncplay_seek = 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.syncplay_unpause;
|
||||
playbackManager.pause = playbackManager.syncplay_pause;
|
||||
playbackManager.seek = playbackManager.syncplay_seek;
|
||||
playbackManager.syncplayEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides PlaybackManager's unpause method.
|
||||
*/
|
||||
playRequest (player) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
apiClient.sendSyncplayCommand(sessionId, "PlayRequest");
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides PlaybackManager's pause method.
|
||||
*/
|
||||
pauseRequest (player) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
apiClient.sendSyncplayCommand(sessionId, "PauseRequest");
|
||||
// Pause locally as well, to give the user some little control
|
||||
playbackManager.syncplay_pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides PlaybackManager's seek method.
|
||||
*/
|
||||
seekRequest (PositionTicks, player) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
apiClient.sendSyncplayCommand(sessionId, "SeekRequest", {
|
||||
PositionTicks: PositionTicks
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes time difference between this client's time and server's time.
|
||||
* @param {Date} pingStartTime Local time when ping request started.
|
||||
* @param {Date} pingEndTime Local time when ping request ended.
|
||||
* @param {Date} serverTime Server UTC time at ping request.
|
||||
*/
|
||||
updateTimeDiff (pingStartTime, pingEndTime, serverTime) {
|
||||
this.roundTripDuration = (pingEndTime - pingStartTime);
|
||||
// The faster the response, the closer we are to the real timeDiff value
|
||||
// localTime = pingStartTime + roundTripDuration / 2
|
||||
// newTimeDiff = localTime - serverTime
|
||||
var newTimeDiff = (pingStartTime - serverTime) + (this.roundTripDuration / 2);
|
||||
|
||||
// Initial setup
|
||||
if (this.initTimeDiff === 0) {
|
||||
this.timeDiff = newTimeDiff;
|
||||
this.initTimeDiff++
|
||||
return;
|
||||
}
|
||||
|
||||
// As response time gets better, absolute value should decrease
|
||||
var distanceFromZero = Math.abs(newTimeDiff);
|
||||
var oldDistanceFromZero = Math.abs(this.timeDiff);
|
||||
if (distanceFromZero < oldDistanceFromZero) {
|
||||
this.timeDiff = newTimeDiff;
|
||||
}
|
||||
|
||||
// Avoid overloading server
|
||||
if (this.initTimeDiff >= GreedyPingCount) {
|
||||
this.pingIntervalTimeout = PingIntervalTimeoutLowProfile;
|
||||
} else {
|
||||
this.initTimeDiff++;
|
||||
}
|
||||
|
||||
// console.debug("Syncplay updateTimeDiff:", serverTime, this.timeDiff, this.roundTripDuration, newTimeDiff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a ping request to the server. Used to compute time difference between client and server.
|
||||
*/
|
||||
requestPing () {
|
||||
if (this.pingInterval === null && !this.pingStop) {
|
||||
this.pingInterval = setTimeout(() => {
|
||||
this.pingInterval = null;
|
||||
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
|
||||
var pingStartTime = new Date();
|
||||
apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then((response) => {
|
||||
var pingEndTime = new Date();
|
||||
response.text().then((utcTime) => {
|
||||
var serverTime = new Date(utcTime);
|
||||
this.updateTimeDiff(pingStartTime, pingEndTime, serverTime);
|
||||
|
||||
// Alert user that ping is high
|
||||
if (Math.abs(this.roundTripDuration) >= 1000) {
|
||||
events.trigger(this, "SyncplayError", [true]);
|
||||
} else {
|
||||
events.trigger(this, "SyncplayError", [false]);
|
||||
}
|
||||
|
||||
// Notify server of ping
|
||||
apiClient.sendSyncplayCommand(sessionId, "KeepAlive", {
|
||||
Ping: this.roundTripDuration / 2
|
||||
});
|
||||
|
||||
if (this.notifySyncplayReady) {
|
||||
this.syncplayReady = true;
|
||||
events.trigger(this, "SyncplayReady");
|
||||
this.notifySyncplayReady = false;
|
||||
}
|
||||
|
||||
this.requestPing();
|
||||
});
|
||||
});
|
||||
|
||||
}, this.pingIntervalTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the keep alive poller.
|
||||
*/
|
||||
startPing () {
|
||||
this.notifySyncplayReady = true;
|
||||
this.pingStop = false;
|
||||
this.initTimeDiff = this.initTimeDiff > this.greedyPingCount ? 1 : this.initTimeDiff;
|
||||
this.pingIntervalTimeout = this.pingIntervalTimeoutGreedy;
|
||||
|
||||
this.requestPing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the keep alive poller.
|
||||
*/
|
||||
stopPing () {
|
||||
this.pingStop = true;
|
||||
if (this.pingInterval !== null) {
|
||||
clearTimeout(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts server time to local time.
|
||||
* @param {Date} server
|
||||
* @returns {Date} Local time.
|
||||
*/
|
||||
serverDateToLocal (server) {
|
||||
// local - server = diff
|
||||
return new Date(server.getTime() + this.timeDiff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts local time to server time.
|
||||
* @param {Date} local
|
||||
* @returns {Date} Server time.
|
||||
*/
|
||||
localDateToServer (local) {
|
||||
// local - server = diff
|
||||
return new Date(local.getTime() - this.timeDiff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Syncplay status.
|
||||
* @returns {boolean} _true_ is user joined a group, _false_ otherwise.
|
||||
*/
|
||||
isSyncplayEnabled () {
|
||||
return this.syncplayEnabledAt !== null ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Syncplay stats.
|
||||
* @returns {Object} The Syncplay stats.
|
||||
*/
|
||||
getStats () {
|
||||
return {
|
||||
TimeDiff: this.timeDiff,
|
||||
PlaybackDiff: this.playbackDiffMillis,
|
||||
SyncMethod: this.syncMethod
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event to update the Syncplay status icon.
|
||||
*/
|
||||
showSyncIcon (syncMethod) {
|
||||
this.syncMethod = syncMethod;
|
||||
events.trigger(this, "SyncplayError", [true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event to clear the Syncplay status icon.
|
||||
*/
|
||||
clearSyncIcon () {
|
||||
this.syncMethod = "None";
|
||||
events.trigger(this, "SyncplayError", [false]);
|
||||
}
|
||||
}
|
||||
|
||||
/** SyncplayManager singleton. */
|
||||
export default new SyncplayManager();
|
|
@ -1,600 +0,0 @@
|
|||
define(['events', 'globalize', 'loading', 'connectionManager', 'playbackManager'], function (events, globalize, loading, connectionManager, playbackManager) {
|
||||
'use strict';
|
||||
|
||||
function waitForEvent(emitter, eventType) {
|
||||
return new Promise(function (resolve) {
|
||||
var callback = function () {
|
||||
events.off(emitter, eventType, callback);
|
||||
resolve(arguments);
|
||||
};
|
||||
events.on(emitter, eventType, callback);
|
||||
});
|
||||
}
|
||||
|
||||
function displaySyncplayUpdate(message) {
|
||||
require(['toast'], function (alert) {
|
||||
alert({
|
||||
title: message.Header,
|
||||
text: message.Text
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getActivePlayerId() {
|
||||
var info = playbackManager.getPlayerInfo();
|
||||
return info ? info.id : null;
|
||||
}
|
||||
|
||||
function millisecondsToTicks(milliseconds) {
|
||||
return milliseconds * 10000;
|
||||
}
|
||||
|
||||
function ticksToMilliseconds(ticks) {
|
||||
return ticks / 10000;
|
||||
}
|
||||
|
||||
function SyncplayManager() {
|
||||
|
||||
var self = this;
|
||||
|
||||
function onPlayerChange() {
|
||||
bindToPlayer(playbackManager.getCurrentPlayer());
|
||||
events.trigger(self, "PlayerChange", [self.currentPlayer]);
|
||||
}
|
||||
|
||||
function onPlayPauseStateChanged(e) {
|
||||
events.trigger(self, "PlayPauseStateChange", [self.currentPlayer]);
|
||||
}
|
||||
|
||||
self.playbackRateSupported = false;
|
||||
self.syncEnabled = false;
|
||||
self.maxAcceptedDelaySpeedToSync = 50; // milliseconds
|
||||
self.maxAcceptedDelaySkipToSync = 300; // milliseconds
|
||||
self.syncMethodThreshold = 2000; // milliseconds
|
||||
self.speedUpToSyncTime = 1000; // milliseconds
|
||||
self.playbackDiffMillis = 0; // used for stats
|
||||
self.syncMethod = "None"; // used for stats
|
||||
|
||||
function onTimeUpdate(e) {
|
||||
events.trigger(self, "TimeUpdate", [e]);
|
||||
|
||||
if (self.lastCommand && self.lastCommand.Command === 'Play' && !self.isBuffering()) {
|
||||
var currentTime = new Date();
|
||||
var playAtTime = self.lastCommand.When;
|
||||
|
||||
var state = playbackManager.getPlayerState().PlayState;
|
||||
// Estimate PositionTicks on server
|
||||
var ServerPositionTicks = self.lastCommand.PositionTicks + ((currentTime - playAtTime) - self.timeDiff) * 10000;
|
||||
// Measure delay that needs to be recovered
|
||||
// diff might be caused by the player internally starting the playback
|
||||
var diff = ServerPositionTicks - state.PositionTicks;
|
||||
var diffMillis = diff / 10000;
|
||||
|
||||
self.playbackDiffMillis = diffMillis;
|
||||
|
||||
// console.debug("Syncplay onTimeUpdate", diffMillis, state.PositionTicks, ServerPositionTicks);
|
||||
|
||||
if (self.syncEnabled) {
|
||||
var absDiffMillis = Math.abs(diffMillis);
|
||||
// TODO: SpeedToSync sounds bad on songs
|
||||
if (self.playbackRateSupported && absDiffMillis > self.maxAcceptedDelaySpeedToSync && absDiffMillis < self.syncMethodThreshold) {
|
||||
// SpeedToSync method
|
||||
var speed = 1 + diffMillis / self.speedUpToSyncTime;
|
||||
|
||||
self.currentPlayer.setPlaybackRate(speed);
|
||||
self.syncEnabled = false;
|
||||
self.showSyncIcon("SpeedToSync (x" + speed + ")");
|
||||
|
||||
self.syncTimeout = setTimeout(() => {
|
||||
self.currentPlayer.setPlaybackRate(1);
|
||||
self.syncEnabled = true;
|
||||
self.clearSyncIcon();
|
||||
}, self.speedUpToSyncTime);
|
||||
} else if (absDiffMillis > self.maxAcceptedDelaySkipToSync) {
|
||||
// SkipToSync method
|
||||
playbackManager.syncplay_seek(ServerPositionTicks);
|
||||
self.syncEnabled = false;
|
||||
self.showSyncIcon("SkipToSync");
|
||||
|
||||
self.syncTimeout = setTimeout(() => {
|
||||
self.syncEnabled = true;
|
||||
self.clearSyncIcon();
|
||||
}, self.syncMethodThreshold / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.lastPlaybackWaiting = null; // used to determine if player's buffering
|
||||
self.minBufferingThresholdMillis = 1000;
|
||||
|
||||
// TODO: implement group wait
|
||||
function onPlaying() {
|
||||
self.lastPlaybackWaiting = null;
|
||||
events.trigger(self, "PlayerPlaying");
|
||||
}
|
||||
|
||||
// TODO: implement group wait
|
||||
function onWaiting() {
|
||||
if (!self.lastPlaybackWaiting) {
|
||||
self.lastPlaybackWaiting = new Date();
|
||||
}
|
||||
events.trigger(self, "PlayerWaiting");
|
||||
}
|
||||
|
||||
self.isBuffering = function () {
|
||||
if (self.lastPlaybackWaiting === null) return false;
|
||||
return (new Date() - self.lastPlaybackWaiting) > self.minBufferingThresholdMillis;
|
||||
};
|
||||
|
||||
function bindToPlayer(player) {
|
||||
if (player !== self.currentPlayer) {
|
||||
releaseCurrentPlayer();
|
||||
self.currentPlayer = player;
|
||||
if (!player) return;
|
||||
}
|
||||
events.on(player, "pause", onPlayPauseStateChanged);
|
||||
events.on(player, "unpause", onPlayPauseStateChanged);
|
||||
events.on(player, "timeupdate", onTimeUpdate);
|
||||
events.on(player, "playing", onPlaying);
|
||||
events.on(player, "waiting", onWaiting);
|
||||
self.playbackRateSupported = player.supports("PlaybackRate");
|
||||
}
|
||||
|
||||
function releaseCurrentPlayer() {
|
||||
var player = self.currentPlayer;
|
||||
if (player) {
|
||||
events.off(player, "pause", onPlayPauseStateChanged);
|
||||
events.off(player, "unpause", onPlayPauseStateChanged);
|
||||
events.off(player, "timeupdate", onTimeUpdate);
|
||||
events.off(player, "playing", onPlaying);
|
||||
events.off(player, "waiting", onWaiting);
|
||||
if (self.playbackRateSupported) {
|
||||
player.setPlaybackRate(1);
|
||||
}
|
||||
self.currentPlayer = null;
|
||||
self.playbackRateSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
self.currentPlayer = null;
|
||||
|
||||
events.on(playbackManager, "playerchange", onPlayerChange);
|
||||
bindToPlayer(playbackManager.getCurrentPlayer());
|
||||
|
||||
self.syncplayEnabledAt = null; // Server time of when Syncplay has been enabled
|
||||
self.syncplayReady = false; // Syncplay is ready after first ping to server
|
||||
|
||||
self.processGroupUpdate = function (cmd, apiClient) {
|
||||
switch (cmd.Type) {
|
||||
case 'PrepareSession':
|
||||
var serverId = apiClient.serverInfo().Id;
|
||||
playbackManager.play({
|
||||
ids: cmd.Data.ItemIds,
|
||||
startPositionTicks: cmd.Data.StartPositionTicks,
|
||||
mediaSourceId: cmd.Data.MediaSourceId,
|
||||
audioStreamIndex: cmd.Data.AudioStreamIndex,
|
||||
subtitleStreamIndex: cmd.Data.SubtitleStreamIndex,
|
||||
startIndex: cmd.Data.StartIndex,
|
||||
serverId: serverId
|
||||
}).then(function () {
|
||||
waitForEvent(self, "PlayerChange").then(function () {
|
||||
playbackManager.pause();
|
||||
var sessionId = getActivePlayerId();
|
||||
if (!sessionId) {
|
||||
console.error("Missing sessionId!");
|
||||
displaySyncplayUpdate({
|
||||
Text: "Failed to enable Syncplay!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Sometimes JoinGroup fails, maybe because server hasn't been updated yet
|
||||
setTimeout(() => {
|
||||
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
|
||||
GroupId: cmd.GroupId
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
break;
|
||||
case 'UserJoined':
|
||||
displaySyncplayUpdate({
|
||||
Text: globalize.translate('MessageSyncplayUserJoined', cmd.Data)
|
||||
});
|
||||
break;
|
||||
case 'UserLeft':
|
||||
displaySyncplayUpdate({
|
||||
Text: globalize.translate('MessageSyncplayUserLeft', cmd.Data)
|
||||
});
|
||||
break;
|
||||
case 'GroupJoined':
|
||||
displaySyncplayUpdate({
|
||||
Text: globalize.translate('MessageSyncplayEnabled')
|
||||
});
|
||||
// Enable Syncplay
|
||||
self.syncplayEnabledAt = new Date(cmd.Data);
|
||||
self.syncplayReady = false;
|
||||
events.trigger(self, "SyncplayEnabled", [true]);
|
||||
waitForEvent(self, "SyncplayReady").then(function () {
|
||||
self.processCommand(self.queuedCommand, apiClient);
|
||||
self.queuedCommand = null;
|
||||
});
|
||||
self.injectPlaybackManager();
|
||||
self.startPing();
|
||||
break;
|
||||
case 'NotInGroup':
|
||||
case 'GroupLeft':
|
||||
displaySyncplayUpdate({
|
||||
Text: globalize.translate('MessageSyncplayDisabled')
|
||||
});
|
||||
// Disable Syncplay
|
||||
self.syncplayEnabledAt = null;
|
||||
self.syncplayReady = false;
|
||||
events.trigger(self, "SyncplayEnabled", [false]);
|
||||
self.restorePlaybackManager();
|
||||
self.stopPing();
|
||||
break;
|
||||
case 'GroupWait':
|
||||
displaySyncplayUpdate({
|
||||
Text: globalize.translate('MessageSyncplayGroupWait', cmd.Data)
|
||||
});
|
||||
break;
|
||||
case 'KeepAlive':
|
||||
break;
|
||||
default:
|
||||
console.error('processSyncplayGroupUpdate does not recognize: ' + cmd.Type);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
self.lastCommand = null;
|
||||
self.queuedCommand = null;
|
||||
|
||||
self.processCommand = function (cmd, apiClient) {
|
||||
if (cmd === null) return;
|
||||
|
||||
if (!self.isSyncplayEnabled()) {
|
||||
console.debug("Syncplay processCommand: ignoring command", cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.syncplayReady) {
|
||||
console.debug("Syncplay processCommand: queued command", cmd);
|
||||
self.queuedCommand = cmd;
|
||||
return;
|
||||
}
|
||||
|
||||
cmd.When = new Date(cmd.When);
|
||||
|
||||
if (cmd.When < self.syncplayEnabledAt) {
|
||||
console.debug("Syncplay processCommand: ignoring old command", cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if new command differs from last one
|
||||
if (self.lastCommand &&
|
||||
self.lastCommand.When === cmd.When &&
|
||||
self.lastCommand.PositionTicks === cmd.PositionTicks &&
|
||||
self.Command === cmd.Command
|
||||
) {
|
||||
console.debug("Syncplay processCommand: ignoring duplicate command", cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
self.lastCommand = cmd;
|
||||
console.log("Syncplay will", cmd.Command, "at", cmd.When, "PositionTicks", cmd.PositionTicks);
|
||||
|
||||
switch (cmd.Command) {
|
||||
case 'Play':
|
||||
self.schedulePlay(cmd.When, cmd.PositionTicks);
|
||||
break;
|
||||
case 'Pause':
|
||||
self.schedulePause(cmd.When, cmd.PositionTicks);
|
||||
break;
|
||||
case 'Seek':
|
||||
self.scheduleSeek(cmd.When, cmd.PositionTicks);
|
||||
break;
|
||||
default:
|
||||
console.error('processSyncplayCommand does not recognize: ' + cmd.Type);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
self.scheduledCommand = null;
|
||||
self.syncTimeout = null;
|
||||
|
||||
self.schedulePlay = function (playAtTime, positionTicks) {
|
||||
self.clearScheduledCommand();
|
||||
var currentTime = new Date();
|
||||
var playAtTimeLocal = self.serverDateToLocal(playAtTime);
|
||||
|
||||
if (playAtTimeLocal > currentTime) {
|
||||
var playTimeout = (playAtTimeLocal - currentTime) - self.playerDelay;
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
|
||||
self.scheduledCommand = setTimeout(() => {
|
||||
playbackManager.syncplay_unpause();
|
||||
|
||||
self.syncTimeout = setTimeout(() => {
|
||||
self.syncEnabled = true
|
||||
}, self.syncMethodThreshold / 2);
|
||||
|
||||
}, playTimeout);
|
||||
|
||||
// console.debug("Syncplay schedulePlay:", playTimeout);
|
||||
} else {
|
||||
// Group playback already started
|
||||
var serverPositionTicks = positionTicks + (currentTime - playAtTimeLocal) * 10000;
|
||||
playbackManager.syncplay_unpause();
|
||||
playbackManager.syncplay_seek(serverPositionTicks);
|
||||
|
||||
self.syncTimeout = setTimeout(() => {
|
||||
self.syncEnabled = true
|
||||
}, self.syncMethodThreshold / 2);
|
||||
}
|
||||
};
|
||||
|
||||
self.schedulePause = function (pauseAtTime, positionTicks) {
|
||||
self.clearScheduledCommand();
|
||||
var currentTime = new Date();
|
||||
var pauseAtTimeLocal = self.serverDateToLocal(pauseAtTime);
|
||||
|
||||
if (pauseAtTimeLocal > currentTime) {
|
||||
var pauseTimeout = (pauseAtTimeLocal - currentTime) - self.playerDelay;
|
||||
|
||||
self.scheduledCommand = setTimeout(() => {
|
||||
playbackManager.syncplay_pause();
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
}, 800);
|
||||
|
||||
}, pauseTimeout);
|
||||
} else {
|
||||
playbackManager.syncplay_pause();
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
self.scheduleSeek = function (seekAtTime, positionTicks) {
|
||||
self.schedulePause(seekAtTime, positionTicks);
|
||||
};
|
||||
|
||||
self.clearScheduledCommand = function () {
|
||||
clearTimeout(self.scheduledCommand);
|
||||
clearTimeout(self.syncTimeout);
|
||||
|
||||
self.syncEnabled = false;
|
||||
if (self.currentPlayer) {
|
||||
self.currentPlayer.setPlaybackRate(1);
|
||||
}
|
||||
self.clearSyncIcon();
|
||||
};
|
||||
|
||||
self.injectPlaybackManager = function () {
|
||||
if (!self.isSyncplayEnabled()) return;
|
||||
if (playbackManager.syncplayEnabled) return;
|
||||
|
||||
playbackManager.syncplay_unpause = playbackManager.unpause;
|
||||
playbackManager.syncplay_pause = playbackManager.pause;
|
||||
playbackManager.syncplay_seek = playbackManager.seek;
|
||||
|
||||
playbackManager.unpause = self.playRequest;
|
||||
playbackManager.pause = self.pauseRequest;
|
||||
playbackManager.seek = self.seekRequest;
|
||||
playbackManager.syncplayEnabled = true;
|
||||
};
|
||||
|
||||
self.restorePlaybackManager = function () {
|
||||
if (self.isSyncplayEnabled()) return;
|
||||
if (!playbackManager.syncplayEnabled) return;
|
||||
|
||||
playbackManager.unpause = playbackManager.syncplay_unpause;
|
||||
playbackManager.pause = playbackManager.syncplay_pause;
|
||||
playbackManager.seek = playbackManager.syncplay_seek;
|
||||
playbackManager.syncplayEnabled = false;
|
||||
};
|
||||
|
||||
self.playRequest = function (player) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
apiClient.sendSyncplayCommand(sessionId, "PlayRequest");
|
||||
};
|
||||
|
||||
self.pauseRequest = function (player) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
apiClient.sendSyncplayCommand(sessionId, "PauseRequest");
|
||||
// Pause locally as well, to give the user some little control
|
||||
playbackManager.syncplay_pause();
|
||||
};
|
||||
|
||||
self.seekRequest = function (PositionTicks, player) {
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
apiClient.sendSyncplayCommand(sessionId, "SeekRequest", {
|
||||
PositionTicks: PositionTicks
|
||||
});
|
||||
};
|
||||
|
||||
self.pingIntervalTimeoutGreedy = 1000;
|
||||
self.pingIntervalTimeoutLowProfile = 60000;
|
||||
self.greedyPingCount = 3;
|
||||
|
||||
self.pingStop = true;
|
||||
self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy;
|
||||
self.pingInterval = null;
|
||||
self.initTimeDiff = 0; // number of pings
|
||||
self.timeDiff = 0; // local time minus server time
|
||||
self.roundTripDuration = 0;
|
||||
self.notifySyncplayReady = false;
|
||||
|
||||
self.updateTimeDiff = function (pingStartTime, pingEndTime, serverTime) {
|
||||
self.roundTripDuration = (pingEndTime - pingStartTime);
|
||||
// The faster the response, the closer we are to the real timeDiff value
|
||||
// localTime = pingStartTime + roundTripDuration / 2
|
||||
// newTimeDiff = localTime - serverTime
|
||||
var newTimeDiff = (pingStartTime - serverTime) + (self.roundTripDuration / 2);
|
||||
|
||||
// Initial setup
|
||||
if (self.initTimeDiff === 0) {
|
||||
self.timeDiff = newTimeDiff;
|
||||
self.initTimeDiff++
|
||||
return;
|
||||
}
|
||||
|
||||
// As response time gets better, absolute value should decrease
|
||||
var distanceFromZero = Math.abs(newTimeDiff);
|
||||
var oldDistanceFromZero = Math.abs(self.timeDiff);
|
||||
if (distanceFromZero < oldDistanceFromZero) {
|
||||
self.timeDiff = newTimeDiff;
|
||||
}
|
||||
|
||||
// Avoid overloading server
|
||||
if (self.initTimeDiff >= self.greedyPingCount) {
|
||||
self.pingIntervalTimeout = self.pingIntervalTimeoutLowProfile;
|
||||
} else {
|
||||
self.initTimeDiff++;
|
||||
}
|
||||
|
||||
// console.debug("Syncplay updateTimeDiff:", serverTime, self.timeDiff, self.roundTripDuration, newTimeDiff);
|
||||
};
|
||||
|
||||
self.requestPing = function () {
|
||||
if (self.pingInterval === null && !self.pingStop) {
|
||||
self.pingInterval = setTimeout(() => {
|
||||
self.pingInterval = null;
|
||||
|
||||
var apiClient = connectionManager.currentApiClient();
|
||||
var sessionId = getActivePlayerId();
|
||||
|
||||
var pingStartTime = new Date();
|
||||
apiClient.sendSyncplayCommand(sessionId, "GetUtcTime").then(function (response) {
|
||||
var pingEndTime = new Date();
|
||||
response.text().then(function (utcTime) {
|
||||
var serverTime = new Date(utcTime);
|
||||
self.updateTimeDiff(pingStartTime, pingEndTime, serverTime);
|
||||
|
||||
// Alert user that ping is high
|
||||
if (Math.abs(self.roundTripDuration) >= 1000) {
|
||||
events.trigger(self, "SyncplayError", [true]);
|
||||
} else {
|
||||
events.trigger(self, "SyncplayError", [false]);
|
||||
}
|
||||
|
||||
// Notify server of ping
|
||||
apiClient.sendSyncplayCommand(sessionId, "KeepAlive", {
|
||||
Ping: (self.roundTripDuration / 2) + self.playerDelay
|
||||
});
|
||||
|
||||
if (self.notifySyncplayReady) {
|
||||
self.syncplayReady = true;
|
||||
events.trigger(self, "SyncplayReady");
|
||||
self.notifySyncplayReady = false;
|
||||
}
|
||||
|
||||
self.requestPing();
|
||||
});
|
||||
});
|
||||
|
||||
}, self.pingIntervalTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
self.startPing = function () {
|
||||
self.notifySyncplayReady = true;
|
||||
self.pingStop = false;
|
||||
self.initTimeDiff = self.initTimeDiff > self.greedyPingCount ? 1 : self.initTimeDiff;
|
||||
self.pingIntervalTimeout = self.pingIntervalTimeoutGreedy;
|
||||
|
||||
self.requestPing();
|
||||
};
|
||||
|
||||
self.stopPing = function () {
|
||||
self.pingStop = true;
|
||||
if (self.pingInterval !== null) {
|
||||
clearTimeout(self.pingInterval);
|
||||
self.pingInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
self.serverDateToLocal = function (server) {
|
||||
// local - server = diff
|
||||
return new Date(server.getTime() + self.timeDiff);
|
||||
};
|
||||
|
||||
self.localDateToServer = function (local) {
|
||||
// local - server = diff
|
||||
return new Date(local.getTime() - self.timeDiff);
|
||||
};
|
||||
|
||||
// THIS FEATURE IS CURRENTLY DISABLED
|
||||
// Mainly because SpeedToSync seems to do the job
|
||||
// Also because the delay is unreliable and different every time
|
||||
self.playerDelay = 0;
|
||||
self.playerDelayMeasured = true; // disable this feature
|
||||
self.measurePlayerDelay = function (positionTicks) {
|
||||
if (self.playerDelayMeasured) {
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
} else {
|
||||
// Measure playerDelay by issuing a play command
|
||||
// followed by a pause command after one second
|
||||
// PositionTicks should be at 1 second minus two times the player delay
|
||||
loading.show();
|
||||
self.currentPlayer.setPlaybackRate(1);
|
||||
playbackManager.syncplay_seek(0);
|
||||
// Wait for player to seek
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_unpause();
|
||||
// Play one second of media
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_pause();
|
||||
// Wait for state to get update
|
||||
setTimeout(() => {
|
||||
var state = playbackManager.getPlayerState().PlayState;
|
||||
var delayTicks = millisecondsToTicks(1000) - state.PositionTicks;
|
||||
var delayMillis = ticksToMilliseconds(delayTicks);
|
||||
self.playerDelay = delayMillis / 2;
|
||||
// Make sure delay is not negative
|
||||
self.playerDelay = self.playerDelay > 0 ? self.playerDelay : 0;
|
||||
self.playerDelayMeasured = true;
|
||||
// console.debug("Syncplay PlayerDelay:", self.playerDelay);
|
||||
// Restore player
|
||||
setTimeout(() => {
|
||||
playbackManager.syncplay_seek(positionTicks);
|
||||
loading.hide();
|
||||
}, 800);
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Stats
|
||||
self.isSyncplayEnabled = function () {
|
||||
return self.syncplayEnabledAt !== null ? true : false;
|
||||
};
|
||||
|
||||
self.getStats = function () {
|
||||
return {
|
||||
TimeDiff: self.timeDiff,
|
||||
PlaybackDiff: self.playbackDiffMillis,
|
||||
SyncMethod: self.syncMethod
|
||||
}
|
||||
};
|
||||
|
||||
// UI
|
||||
self.showSyncIcon = function (syncMethod) {
|
||||
self.syncMethod = syncMethod;
|
||||
events.trigger(self, "SyncplayError", [true]);
|
||||
};
|
||||
|
||||
self.clearSyncIcon = function () {
|
||||
self.syncMethod = "None";
|
||||
events.trigger(self, "SyncplayError", [false]);
|
||||
};
|
||||
}
|
||||
|
||||
return new SyncplayManager();
|
||||
});
|
|
@ -314,6 +314,13 @@ var AppInfo = {};
|
|||
return obj;
|
||||
}
|
||||
|
||||
function returnDefault(obj) {
|
||||
if (obj.default === null) {
|
||||
throw new Error("Object has no default!");
|
||||
}
|
||||
return obj.default;
|
||||
}
|
||||
|
||||
function getBowerPath() {
|
||||
return 'libraries';
|
||||
}
|
||||
|
@ -817,7 +824,7 @@ var AppInfo = {};
|
|||
define('playbackSettings', [componentsPath + '/playbacksettings/playbacksettings'], returnFirstDependency);
|
||||
define('homescreenSettings', [componentsPath + '/homescreensettings/homescreensettings'], returnFirstDependency);
|
||||
define('playbackManager', [componentsPath + '/playback/playbackmanager'], getPlaybackManager);
|
||||
define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnFirstDependency);
|
||||
define('syncplayManager', [componentsPath + '/syncplay/syncplaymanager'], returnDefault);
|
||||
define('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager);
|
||||
define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency);
|
||||
define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue