Implement basic SyncPlay settings

This commit is contained in:
Ionut Andrei Oanca 2020-10-14 20:40:46 +01:00 committed by Bill Thornton
parent 4a5051317b
commit 94c405f08e
20 changed files with 618 additions and 17 deletions

View file

@ -5,6 +5,7 @@
import { Events } from 'jellyfin-apiclient';
import * as Helper from './Helper';
import Settings from './Settings';
import TimeSyncCore from './timeSync/TimeSyncCore';
import PlaybackCore from './PlaybackCore';
import QueueCore from './QueueCore';

View file

@ -5,6 +5,7 @@
import { Events } from 'jellyfin-apiclient';
import * as Helper from './Helper';
import Settings from './Settings';
/**
* Class that manages the playback of SyncPlay.
@ -25,6 +26,8 @@ class PlaybackCore {
this.lastCommand = null; // Last scheduled playback command, might not be the latest one.
this.scheduledCommandTimeout = null;
this.syncTimeout = null;
this.loadPreferences();
}
/**
@ -35,26 +38,35 @@ class PlaybackCore {
this.manager = syncPlayManager;
this.timeSyncCore = syncPlayManager.getTimeSyncCore();
Events.on(Settings, 'update', (event) => {
this.loadPreferences();
});
}
/**
* Loads preferences from saved settings.
*/
loadPreferences() {
// Minimum required delay for SpeedToSync to kick in, in milliseconds.
this.minDelaySpeedToSync = 60.0;
this.minDelaySpeedToSync = Settings.getFloat('minDelaySpeedToSync', 60.0);
// Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds.
this.maxDelaySpeedToSync = 3000.0;
this.maxDelaySpeedToSync = Settings.getFloat('maxDelaySpeedToSync', 3000.0);
// Time during which the playback is sped up, in milliseconds.
this.speedToSyncDuration = 1000.0;
this.speedToSyncDuration = Settings.getFloat('speedToSyncDuration', 1000.0);
// Minimum required delay for SkipToSync to kick in, in milliseconds.
this.minDelaySkipToSync = 400.0;
this.minDelaySkipToSync = Settings.getFloat('minDelaySkipToSync', 400.0);
// Whether SpeedToSync should be used.
this.useSpeedToSync = true;
this.useSpeedToSync = Settings.getBool('useSpeedToSync', true);
// Whether SkipToSync should be used.
this.useSkipToSync = true;
this.useSkipToSync = Settings.getBool('useSkipToSync', true);
// Whether sync correction during playback is active.
this.enableSyncCorrection = true;
this.enableSyncCorrection = Settings.getBool('enableSyncCorrection', true);
}
/**
@ -526,7 +538,12 @@ class PlaybackCore {
// Diff might be caused by the player internally starting the playback.
const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond;
// Adapt playback diff to selected device for time syncing.
const targetPlaybackDiff = diffMillis - this.timeSyncCore.getPlaybackDiff();
// Notify update for playback sync.
this.playbackDiffMillis = diffMillis;
Events.trigger(this.manager, 'playback-diff', [this.playbackDiffMillis]);
// Avoid overloading the browser.
const elapsed = currentTime - this.lastSyncTime;
@ -536,22 +553,22 @@ class PlaybackCore {
const playerWrapper = this.manager.getPlayerWrapper();
if (this.syncEnabled && this.enableSyncCorrection) {
const absDiffMillis = Math.abs(diffMillis);
const absDiffMillis = Math.abs(targetPlaybackDiff);
// 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);
if (targetPlaybackDiff <= -speedToSyncTime * MinSpeed) {
speedToSyncTime = Math.abs(targetPlaybackDiff) / (1.0 - MinSpeed);
}
// SpeedToSync strategy.
const speed = 1 + diffMillis / speedToSyncTime;
const speed = 1 + targetPlaybackDiff / speedToSyncTime;
if (speed <= 0) {
console.error('SyncPlay error: speed should not be negative!', speed, diffMillis, speedToSyncTime);
console.error('SyncPlay error: speed should not be negative!', speed, targetPlaybackDiff, speedToSyncTime);
}
playerWrapper.setPlaybackRate(speed);

View file

@ -0,0 +1,84 @@
/**
* Module that manages SyncPlay settings.
* @module components/syncPlay/core/Settings
*/
import { Events, AppStorage } from 'jellyfin-apiclient';
/**
* Class that manages SyncPlay settings.
*/
class SyncPlaySettings {
constructor() {
// Do nothing
}
/**
* Gets the key used to store a setting in the App Storage.
* @param {string} name The name of the setting.
* @returns {string} The key.
*/
getKey(name) {
return 'syncPlay-' + name;
}
/**
* Gets the value of a setting.
* @param {string} name The name of the setting.
* @returns {string} The value.
*/
get(name) {
return AppStorage.getItem(this.getKey(name));
}
/**
* Sets the value of a setting. Triggers an update if the new value differs from the old one.
* @param {string} name The name of the setting.
* @param {Object} value The value of the setting.
*/
set(name, value) {
const oldValue = this.get(name);
AppStorage.setItem(this.getKey(name), value);
const newValue = this.get(name);
if (oldValue !== newValue) {
Events.trigger(this, name, [newValue, oldValue]);
}
console.debug(`SyncPlay Settings set: '${name}' from '${oldValue}' to '${newValue}'.`);
}
/**
* Gets the value of a setting as boolean.
* @param {string} name The name of the setting.
* @param {boolean} defaultValue The default value if the setting does not exist.
* @returns {boolean} The value.
*/
getBool(name, defaultValue = false) {
const value = this.get(name);
if (value !== 'true' && value !== 'false') {
return defaultValue;
} else {
return this.get(name) !== 'false';
}
}
/**
* Gets the value of a setting as float number.
* @param {string} name The name of the setting.
* @param {number} defaultValue The default value if the setting does not exist.
* @returns {number} The value.
*/
getFloat(name, defaultValue = 0) {
const value = this.get(name);
if (value === null || value === '' || isNaN(value)) {
return defaultValue;
} else {
return Number.parseFloat(value);
}
}
}
/** SyncPlaySettings singleton. */
const Settings = new SyncPlaySettings();
export default Settings;

View file

@ -1,4 +1,5 @@
import * as Helper from './Helper';
import Settings from './Settings';
import ManagerClass from './Manager';
import PlayerFactoryClass from './players/PlayerFactory';
import GenericPlayer from './players/GenericPlayer';
@ -8,6 +9,7 @@ const Manager = new ManagerClass(PlayerFactory);
export default {
Helper,
Settings,
Manager,
PlayerFactory,
Players: {

View file

@ -4,6 +4,7 @@
*/
import { Events } from 'jellyfin-apiclient';
import Settings from '../Settings';
import TimeSyncServer from './TimeSyncServer';
/**
@ -13,6 +14,9 @@ class TimeSyncCore {
constructor() {
this.manager = null;
this.timeSyncServer = null;
this.timeSyncDeviceId = Settings.get('timeSyncDevice') || 'server';
this.extraTimeOffset = Settings.getFloat('extraTimeOffset', 0.0);
}
/**
@ -31,6 +35,10 @@ class TimeSyncCore {
Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]);
});
Events.on(Settings, 'extraTimeOffset', (event, value, oldValue) => {
this.extraTimeOffset = Settings.getFloat('extraTimeOffset', 0.0);
});
}
/**
@ -40,6 +48,32 @@ class TimeSyncCore {
this.timeSyncServer.forceUpdate();
}
/**
* Gets the list of available devices for time sync.
* @returns {Array} The list of devices.
*/
getDevices() {
const devices = [{
type: 'server',
id: 'server',
name: 'Server',
timeOffset: this.timeSyncServer.getTimeOffset(),
ping: this.timeSyncServer.getPing(),
peerTimeOffset: 0,
peerPing: 0
}];
return devices;
}
/**
* Gets the identifier of the selected device for time sync. Default value is 'server'.
* @returns {string} The identifier.
*/
getActiveDevice() {
return this.timeSyncDeviceId;
}
/**
* Gets the display name of the selected device for time sync.
* @returns {string} The display name.
@ -54,7 +88,8 @@ class TimeSyncCore {
* @returns {Date} Local time.
*/
remoteDateToLocal(remote) {
return this.timeSyncServer.remoteDateToLocal(remote);
const date = this.timeSyncServer.remoteDateToLocal(remote);
return this.offsetDate(date, -this.extraTimeOffset);
}
/**
@ -63,15 +98,35 @@ class TimeSyncCore {
* @returns {Date} Server time.
*/
localDateToRemote(local) {
return this.timeSyncServer.localDateToRemote(local);
const date = this.timeSyncServer.localDateToRemote(local);
return this.offsetDate(date, this.extraTimeOffset);
}
/**
* Gets time offset that should be used for time syncing, in milliseconds.
* Gets time offset that should be used for time syncing, in milliseconds. Takes into account server and active device selected for syncing.
* @returns {number} The time offset.
*/
getTimeOffset() {
return this.timeSyncServer.getTimeOffset();
return this.timeSyncServer.getTimeOffset() + this.extraTimeOffset;
}
/**
* Gets the playback diff that should be used to offset local playback, in milliseconds.
* @returns {number} The time offset.
*/
getPlaybackDiff() {
// TODO: this will use playback data from WebRTC peers.
return 0;
}
/**
* Offsets a given date by a given ammount of milliseconds.
* @param {Date} date The date.
* @param {number} offset The offset, in milliseconds.
* @returns {Date} The offset date.
*/
offsetDate(date, offset) {
return new Date(date.getTime() + offset);
}
}