Implement syncplay frontend

This commit is contained in:
gion 2020-04-01 17:53:14 +02:00
parent ed12e7c4f9
commit 6c18b655e0
11 changed files with 915 additions and 4 deletions

View file

@ -30,7 +30,7 @@
opacity: 0;
}
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton) {
.osdHeader .headerButton:not(.headerBackButton):not(.headerCastButton):not(.headerSyncButton) {
display: none;
}

View file

@ -171,6 +171,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
elem.addEventListener('pause', onPause);
elem.addEventListener('playing', onPlaying);
elem.addEventListener('play', onPlay);
elem.addEventListener('waiting', onWaiting);
}
function unBindEvents(elem) {
@ -180,6 +181,7 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
elem.removeEventListener('pause', onPause);
elem.removeEventListener('playing', onPlaying);
elem.removeEventListener('play', onPlay);
elem.removeEventListener('waiting', onWaiting);
}
self.stop = function (destroyPlayer) {
@ -294,6 +296,10 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
events.trigger(self, 'pause');
}
function onWaiting() {
events.trigger(self, 'waiting');
}
function onError() {
var errorCode = this.error ? (this.error.code || 0) : 0;
@ -450,6 +456,21 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
return false;
};
HtmlAudioPlayer.prototype.setPlaybackRate = function (value) {
var mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.playbackRate = value;
}
};
HtmlAudioPlayer.prototype.getPlaybackRate = function () {
var mediaElement = this._mediaElement;
if (mediaElement) {
return mediaElement.playbackRate;
}
return null;
};
HtmlAudioPlayer.prototype.setVolume = function (val) {
var mediaElement = this._mediaElement;
if (mediaElement) {
@ -493,5 +514,26 @@ define(['events', 'browser', 'require', 'apphost', 'appSettings', 'htmlMediaHelp
};
var supportedFeatures;
function getSupportedFeatures() {
var list = [];
var audio = document.createElement('audio');
if (typeof audio.playbackRate === "number") {
list.push("PlaybackRate");
}
return list;
}
HtmlAudioPlayer.prototype.supports = function (feature) {
if (!supportedFeatures) {
supportedFeatures = getSupportedFeatures();
}
return supportedFeatures.indexOf(feature) !== -1;
};
return HtmlAudioPlayer;
});

View file

@ -785,6 +785,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
videoElement.removeEventListener('play', onPlay);
videoElement.removeEventListener('click', onClick);
videoElement.removeEventListener('dblclick', onDblClick);
videoElement.removeEventListener('waiting', onWaiting);
videoElement.parentNode.removeChild(videoElement);
}
@ -915,6 +916,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
events.trigger(self, 'pause');
}
function onWaiting() {
events.trigger(self, 'waiting');
}
function onError() {
var errorCode = this.error ? (this.error.code || 0) : 0;
var errorMessage = this.error ? (this.error.message || '') : '';
@ -1348,6 +1353,7 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
videoElement.addEventListener('play', onPlay);
videoElement.addEventListener('click', onClick);
videoElement.addEventListener('dblclick', onDblClick);
videoElement.addEventListener('waiting', onWaiting);
document.body.insertBefore(dlg, document.body.firstChild);
videoDialog = dlg;
@ -1436,6 +1442,10 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
list.push('AirPlay');
}
if (typeof video.playbackRate === "number") {
list.push("PlaybackRate");
}
list.push('SetBrightness');
list.push('SetAspectRatio');
@ -1656,6 +1666,21 @@ define(['browser', 'require', 'events', 'apphost', 'loading', 'dom', 'playbackMa
return false;
};
HtmlVideoPlayer.prototype.setPlaybackRate = function (value) {
var mediaElement = this._mediaElement;
if (mediaElement) {
mediaElement.playbackRate = value;
}
};
HtmlVideoPlayer.prototype.getPlaybackRate = function () {
var mediaElement = this._mediaElement;
if (mediaElement) {
return mediaElement.playbackRate;
}
return null;
};
HtmlVideoPlayer.prototype.setVolume = function (val) {
var mediaElement = this._mediaElement;
if (mediaElement) {

View file

@ -3777,6 +3777,24 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
}
};
PlaybackManager.prototype.setPlaybackRate = function (value, player) {
player = player || this._currentPlayer;
if (player) {
player.setPlaybackRate(value);
}
};
PlaybackManager.prototype.getPlaybackRate = function (player) {
player = player || this._currentPlayer;
if (player) {
return player.getPlaybackRate(val);
}
return null;
};
PlaybackManager.prototype.instantMix = function (item, player) {
player = player || this._currentPlayer;
@ -3887,6 +3905,9 @@ define(['events', 'datetime', 'appSettings', 'itemHelper', 'pluginManager', 'pla
if (player.supports('SetAspectRatio')) {
list.push('SetAspectRatio');
}
if (player.supports('PlaybackRate')) {
list.push('PlaybackRate');
}
}
return list;

View file

@ -1,4 +1,4 @@
define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, playMethodHelper, layoutManager, serverNotifications) {
define(['events', 'globalize', 'playbackManager', 'connectionManager', 'syncplayManager', 'playMethodHelper', 'layoutManager', 'serverNotifications', 'paper-icon-button-light', 'css!./playerstats'], function (events, globalize, playbackManager, connectionManager, syncplayManager, playMethodHelper, layoutManager, serverNotifications) {
'use strict';
function init(instance) {
@ -327,6 +327,28 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth
return sessionStats;
}
function getSyncplayStats() {
var syncStats = [];
var stats = syncplayManager.getStats();
syncStats.push({
label: globalize.translate("LabelSyncplayTimeDiff"),
value: stats.TimeDiff + "ms"
});
syncStats.push({
label: globalize.translate("LabelSyncplayPlaybackDiff"),
value: stats.PlaybackDiff + "ms"
});
syncStats.push({
label: globalize.translate("LabelSyncplaySyncMethod"),
value: stats.SyncMethod
});
return syncStats;
}
function getStats(instance, player) {
var statsPromise = player.getStats ? player.getStats() : Promise.resolve({});
@ -383,6 +405,13 @@ define(['events', 'globalize', 'playbackManager', 'connectionManager', 'playMeth
name: 'Original Media Info'
});
if (syncplayManager.isSyncplayEnabled()) {
categories.push({
stats: getSyncplayStats(),
name: 'Syncplay Info'
});
}
return Promise.resolve(categories);
});
}

View file

@ -1,4 +1,4 @@
define(['connectionManager', 'playbackManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, events, inputManager, focusManager, appRouter) {
define(['connectionManager', 'playbackManager', 'syncplayManager', 'events', 'inputManager', 'focusManager', 'appRouter'], function (connectionManager, playbackManager, syncplayManager, events, inputManager, focusManager, appRouter) {
'use strict';
var serverNotifications = {};
@ -187,6 +187,10 @@ define(['connectionManager', 'playbackManager', 'events', 'inputManager', 'focus
events.trigger(serverNotifications, 'UserDataChanged', [apiClient, msg.Data.UserDataList[i]]);
}
}
} else if (msg.MessageType === "SyncplayCommand") {
syncplayManager.processCommand(msg.Data, apiClient);
} else if (msg.MessageType === "SyncplayGroupUpdate") {
syncplayManager.processGroupUpdate(msg.Data, apiClient);
} else {
events.trigger(serverNotifications, msg.MessageType, [apiClient, msg.Data]);
}

View file

@ -0,0 +1,140 @@
define(['events', 'loading', 'connectionManager', 'playbackManager', 'syncplayManager', 'globalize', 'datetime'], function (events, loading, connectionManager, playbackManager, syncplayManager, globalize, datetime) {
'use strict';
function getActivePlayerId() {
var info = playbackManager.getPlayerInfo();
return info ? info.id : null;
}
function emptyCallback() {
// avoid console logs about uncaught promises
}
function showNewJoinGroupSelection(button) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
sessionId = sessionId ? sessionId : "none";
loading.show();
apiClient.sendSyncplayCommand(sessionId, "ListGroups").then(function (response) {
response.json().then(function (groups) {
var inSession = sessionId !== "none";
var menuItems = groups.map(function (group) {
var name = datetime.getDisplayRunningTime(group.PositionTicks);
if (!inSession) {
name = group.PlayingItemName
}
return {
name: name,
icon: "group",
id: group.GroupId,
selected: false,
secondaryText: group.Partecipants.join(", ")
}
});
if (inSession) {
menuItems.push({
name: globalize.translate('LabelSyncplayNewGroup'),
icon: "add",
id: "new-group",
selected: true,
secondaryText: globalize.translate('LabelSyncplayNewGroupDescription')
});
}
if (menuItems.length === 0) {
require(['toast'], function (alert) {
alert({
title: globalize.translate('MessageSyncplayNoGroupsAvailable'),
text: globalize.translate('MessageSyncplayNoGroupsAvailable')
});
});
loading.hide();
return;
}
require(['actionsheet'], function (actionsheet) {
loading.hide();
var menuOptions = {
title: globalize.translate('HeaderSyncplaySelectGroup'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == "new-group") {
apiClient.sendSyncplayCommand(sessionId, "NewGroup");
} else {
apiClient.sendSyncplayCommand(sessionId, "JoinGroup", {
GroupId: id
});
}
}, emptyCallback);
});
});
}).catch(function (error) {
loading.hide();
console.error(error);
});
}
function showLeaveGroupSelection(button) {
var apiClient = connectionManager.currentApiClient();
var sessionId = getActivePlayerId();
loading.show();
var menuItems = [{
name: globalize.translate('LabelSyncplayLeaveGroup'),
icon: "meeting_room",
id: "leave-group",
selected: true,
secondaryText: globalize.translate('LabelSyncplayLeaveGroupDescription')
}];
require(['actionsheet'], function (actionsheet) {
loading.hide();
var menuOptions = {
title: globalize.translate('HeaderSyncplayEnabled'),
items: menuItems,
positionTo: button,
resolveOnClick: true,
border: true
};
actionsheet.show(menuOptions).then(function (id) {
if (id == "leave-group") {
apiClient.sendSyncplayCommand(sessionId, "LeaveGroup");
}
}, emptyCallback);
});
}
function showGroupSelection(button) {
if (syncplayEnabled) {
showLeaveGroupSelection(button);
} else {
showNewJoinGroupSelection(button);
}
}
var syncplayEnabled = false;
events.on(syncplayManager, 'SyncplayEnabled', function (e, enabled) {
syncplayEnabled = enabled;
});
return {
show: showGroupSelection
};
});

View file

@ -0,0 +1,600 @@
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();
});

View file

@ -1,4 +1,4 @@
define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, browser, globalize, imageHelper) {
define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', 'viewManager', 'libraryBrowser', 'appRouter', 'apphost', 'playbackManager', 'syncplayManager', 'browser', 'globalize', 'scripts/imagehelper', 'paper-icon-button-light', 'material-icons', 'scrollStyles', 'flexStyles'], function (dom, layoutManager, inputManager, connectionManager, events, viewManager, libraryBrowser, appRouter, appHost, playbackManager, syncplayManager, browser, globalize, imageHelper) {
'use strict';
function renderHeader() {
@ -12,6 +12,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
html += '</div>';
html += '<div class="headerRight">';
html += '<span class="headerSelectedPlayer"></span>';
html += '<button is="paper-icon-button-light" class="headerSyncButton syncButton headerButton headerButtonRight hide"><span class="material-icons sync_disabled"></span></button>';
html += '<button is="paper-icon-button-light" class="headerAudioPlayerButton audioPlayerButton headerButton headerButtonRight hide"><span class="material-icons music_note"></span></button>';
html += '<button is="paper-icon-button-light" class="headerCastButton castButton headerButton headerButtonRight hide"><span class="material-icons cast"></span></button>';
html += '<button type="button" is="paper-icon-button-light" class="headerButton headerButtonRight headerSearchButton hide"><span class="material-icons search"></span></button>';
@ -30,6 +31,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
headerCastButton = skinHeader.querySelector('.headerCastButton');
headerAudioPlayerButton = skinHeader.querySelector('.headerAudioPlayerButton');
headerSearchButton = skinHeader.querySelector('.headerSearchButton');
headerSyncButton = skinHeader.querySelector('.headerSyncButton');
lazyLoadViewMenuBarImages();
bindMenuEvents();
@ -93,6 +95,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
}
}
headerSyncButton.classList.remove("hide");
requiresUserRefresh = false;
}
@ -147,6 +151,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
}
headerAudioPlayerButton.addEventListener('click', showAudioPlayer);
headerSyncButton.addEventListener('click', onSyncButtonClicked);
if (layoutManager.mobile) {
initHeadRoom(skinHeader);
@ -177,6 +182,32 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
});
}
function onSyncButtonClicked() {
var btn = this;
require(["groupSelectionMenu"], function (groupSelectionMenu) {
groupSelectionMenu.show(btn);
});
}
function updateSyncplayIcon(event, enabled) {
var icon = headerSyncButton.querySelector("i");
if (enabled) {
icon.innerHTML = "sync";
} else {
icon.innerHTML = "sync_disabled";
}
}
function updateSyncplayErrorIcon(event, show_error) {
var icon = headerSyncButton.querySelector("i");
if (show_error) {
icon.innerHTML = "sync_problem";
} else {
icon.innerHTML = "sync";
}
}
function getItemHref(item, context) {
return appRouter.getRouteUrl(item, {
context: context
@ -799,6 +830,7 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
var headerCastButton;
var headerSearchButton;
var headerAudioPlayerButton;
var headerSyncButton;
var enableLibraryNavDrawer = layoutManager.desktop;
var skinHeader = document.querySelector('.skinHeader');
var requiresUserRefresh = true;
@ -931,6 +963,8 @@ define(['dom', 'layoutManager', 'inputManager', 'connectionManager', 'events', '
updateUserInHeader();
});
events.on(playbackManager, 'playerchange', updateCastIcon);
events.on(syncplayManager, 'SyncplayEnabled', updateSyncplayIcon);
events.on(syncplayManager, 'SyncplayError', updateSyncplayErrorIcon);
loadNavDrawer();
return LibraryMenu;
});

View file

@ -817,6 +817,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('layoutManager', [componentsPath + '/layoutManager', 'apphost'], getLayoutManager);
define('homeSections', [componentsPath + '/homesections/homesections'], returnFirstDependency);
define('playMenu', [componentsPath + '/playmenu'], returnFirstDependency);

View file

@ -491,6 +491,8 @@
"HeaderSubtitleProfile": "Subtitle Profile",
"HeaderSubtitleProfiles": "Subtitle Profiles",
"HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.",
"HeaderSyncplaySelectGroup": "Join a group",
"HeaderSyncplayEnabled": "Syncplay enabled",
"HeaderSystemDlnaProfiles": "System Profiles",
"HeaderTags": "Tags",
"HeaderTaskTriggers": "Task Triggers",
@ -853,6 +855,13 @@
"LabelSubtitlePlaybackMode": "Subtitle mode:",
"LabelSubtitles": "Subtitles",
"LabelSupportedMediaTypes": "Supported Media Types:",
"LabelSyncplayTimeDiff": "Time difference with server:",
"LabelSyncplayPlaybackDiff": "Playback time difference:",
"LabelSyncplaySyncMethod": "Sync method:",
"LabelSyncplayNewGroup": "New group",
"LabelSyncplayNewGroupDescription": "Create a new group",
"LabelSyncplayLeaveGroup": "Leave group",
"LabelSyncplayLeaveGroupDescription": "Disable Syncplay",
"LabelTVHomeScreen": "TV mode home screen:",
"LabelTag": "Tag:",
"LabelTagline": "Tagline:",
@ -1016,6 +1025,12 @@
"MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.",
"MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.",
"MessageYouHaveVersionInstalled": "You currently have version {0} installed.",
"MessageSyncplayEnabled": "Syncplay enabled.",
"MessageSyncplayDisabled": "Syncplay disabled.",
"MessageSyncplayUserJoined": "<b>{0}</b> joined group.",
"MessageSyncplayUserLeft": "<b>{0}</b> left group.",
"MessageSyncplayGroupWait": "<b>{0}</b> is buffering...",
"MessageSyncplayNoGroupsAvailable": "No groups available.",
"Metadata": "Metadata",
"MetadataManager": "Metadata Manager",
"MetadataSettingChangeHelp": "Changing metadata settings will affect new content that is added going forward. To refresh existing content, open the detail screen and click the refresh button, or perform bulk refreshes using the metadata manager.",