diff --git a/src/components/formdialog.scss b/src/components/formdialog.scss index d7cb162e8c..d7a6d4e6a8 100644 --- a/src/components/formdialog.scss +++ b/src/components/formdialog.scss @@ -60,6 +60,7 @@ } .layout-tv .formDialogFooter { + position: relative; align-items: center; justify-content: center; flex-wrap: wrap; diff --git a/src/components/syncPlay/core/PlaybackCore.js b/src/components/syncPlay/core/PlaybackCore.js index 2cab13e784..7003dd50e8 100644 --- a/src/components/syncPlay/core/PlaybackCore.js +++ b/src/components/syncPlay/core/PlaybackCore.js @@ -4,7 +4,9 @@ */ import { Events } from 'jellyfin-apiclient'; +import { toBoolean, toFloat } from '../../../scripts/stringUtils'; import * as Helper from './Helper'; +import { getSetting } from './Settings'; /** * Class that manages the playback of SyncPlay. @@ -25,6 +27,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 +39,35 @@ class PlaybackCore { this.manager = syncPlayManager; this.timeSyncCore = syncPlayManager.getTimeSyncCore(); + Events.on(this.manager, 'settings-update', () => { + this.loadPreferences(); + }); + } + + /** + * Loads preferences from saved settings. + */ + loadPreferences() { // Minimum required delay for SpeedToSync to kick in, in milliseconds. - this.minDelaySpeedToSync = 60.0; + this.minDelaySpeedToSync = toFloat(getSetting('minDelaySpeedToSync'), 60.0); // Maximum delay after which SkipToSync is used instead of SpeedToSync, in milliseconds. - this.maxDelaySpeedToSync = 3000.0; + this.maxDelaySpeedToSync = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0); // Time during which the playback is sped up, in milliseconds. - this.speedToSyncDuration = 1000.0; + this.speedToSyncDuration = toFloat(getSetting('speedToSyncDuration'), 1000.0); // Minimum required delay for SkipToSync to kick in, in milliseconds. - this.minDelaySkipToSync = 400.0; + this.minDelaySkipToSync = toFloat(getSetting('minDelaySkipToSync'), 400.0); // Whether SpeedToSync should be used. - this.useSpeedToSync = true; + this.useSpeedToSync = toBoolean(getSetting('useSpeedToSync'), true); // Whether SkipToSync should be used. - this.useSkipToSync = true; + this.useSkipToSync = toBoolean(getSetting('useSkipToSync'), true); // Whether sync correction during playback is active. - this.enableSyncCorrection = true; + this.enableSyncCorrection = toBoolean(getSetting('enableSyncCorrection'), true); } /** @@ -526,7 +539,9 @@ class PlaybackCore { // Diff might be caused by the player internally starting the playback. const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond; + // 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; diff --git a/src/components/syncPlay/core/Settings.js b/src/components/syncPlay/core/Settings.js new file mode 100644 index 0000000000..1f519fe2c1 --- /dev/null +++ b/src/components/syncPlay/core/Settings.js @@ -0,0 +1,28 @@ +/** + * Module that manages SyncPlay settings. + * @module components/syncPlay/core/Settings + */ +import appSettings from '../../../scripts/settings/appSettings'; + +/** + * Prefix used when saving SyncPlay settings. + */ +const PREFIX = 'syncPlay'; + +/** + * Gets the value of a setting. + * @param {string} name The name of the setting. + * @returns {string} The value. + */ +export function getSetting(name) { + return appSettings.get(name, PREFIX); +} + +/** + * 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. + */ +export function setSetting(name, value) { + return appSettings.set(name, value, PREFIX); +} diff --git a/src/components/syncPlay/core/timeSync/TimeSyncCore.js b/src/components/syncPlay/core/timeSync/TimeSyncCore.js index a67752648d..adb135f28b 100644 --- a/src/components/syncPlay/core/timeSync/TimeSyncCore.js +++ b/src/components/syncPlay/core/timeSync/TimeSyncCore.js @@ -4,8 +4,21 @@ */ import { Events } from 'jellyfin-apiclient'; +import appSettings from '../../../../scripts/settings/appSettings'; +import { toFloat } from '../../../../scripts/stringUtils'; +import { getSetting } from '../Settings'; import TimeSyncServer from './TimeSyncServer'; +/** + * Utility function to offset a given date by a given amount of milliseconds. + * @param {Date} date The date. + * @param {number} offset The offset, in milliseconds. + * @returns {Date} The offset date. + */ +function offsetDate(date, offset) { + return new Date(date.getTime() + offset); +} + /** * Class that manages time syncing with several devices. */ @@ -13,6 +26,9 @@ class TimeSyncCore { constructor() { this.manager = null; this.timeSyncServer = null; + + this.timeSyncDeviceId = getSetting('timeSyncDevice') || 'server'; + this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0); } /** @@ -31,6 +47,12 @@ class TimeSyncCore { Events.trigger(this, 'time-sync-server-update', [timeOffset, ping]); }); + + Events.on(appSettings, 'change', function (e, name) { + if (name === 'extraTimeOffset') { + this.extraTimeOffset = toFloat(getSetting('extraTimeOffset'), 0.0); + } + }); } /** @@ -54,7 +76,8 @@ class TimeSyncCore { * @returns {Date} Local time. */ remoteDateToLocal(remote) { - return this.timeSyncServer.remoteDateToLocal(remote); + const date = this.timeSyncServer.remoteDateToLocal(remote); + return offsetDate(date, -this.extraTimeOffset); } /** @@ -63,15 +86,16 @@ class TimeSyncCore { * @returns {Date} Server time. */ localDateToRemote(local) { - return this.timeSyncServer.localDateToRemote(local); + const date = this.timeSyncServer.localDateToRemote(local); + return 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; } } diff --git a/src/components/syncPlay/ui/groupSelectionMenu.js b/src/components/syncPlay/ui/groupSelectionMenu.js index 96a7310381..86632704fb 100644 --- a/src/components/syncPlay/ui/groupSelectionMenu.js +++ b/src/components/syncPlay/ui/groupSelectionMenu.js @@ -1,5 +1,6 @@ import { Events } from 'jellyfin-apiclient'; import SyncPlay from '../core'; +import SyncPlaySettingsEditor from './settings/SettingsEditor'; import loading from '../../loading/loading'; import toast from '../../toast/toast'; import actionsheet from '../../actionSheet/actionSheet'; @@ -77,7 +78,9 @@ class GroupSelectionMenu { }); } }).catch((error) => { - console.error('SyncPlay: unexpected error listing groups:', error); + if (error) { + console.error('SyncPlay: unexpected error listing groups:', error); + } }); loading.hide(); @@ -119,6 +122,14 @@ class GroupSelectionMenu { }); } + menuItems.push({ + name: globalize.translate('Settings'), + icon: 'video_settings', + id: 'settings', + selected: false, + secondaryText: globalize.translate('LabelSyncPlaySettingsDescription') + }); + menuItems.push({ name: globalize.translate('LabelSyncPlayLeaveGroup'), icon: 'meeting_room', @@ -142,9 +153,19 @@ class GroupSelectionMenu { SyncPlay.Manager.haltGroupPlayback(apiClient); } else if (id == 'leave-group') { apiClient.leaveSyncPlayGroup(); + } else if (id == 'settings') { + new SyncPlaySettingsEditor(apiClient, SyncPlay.Manager.getTimeSyncCore(), { groupInfo: groupInfo }) + .embed() + .catch(error => { + if (error) { + console.error('Error creating SyncPlay settings editor', error); + } + }); } }).catch((error) => { - console.error('SyncPlay: unexpected error showing group menu:', error); + if (error) { + console.error('SyncPlay: unexpected error showing group menu:', error); + } }); loading.hide(); diff --git a/src/components/syncPlay/ui/settings/SettingsEditor.js b/src/components/syncPlay/ui/settings/SettingsEditor.js new file mode 100644 index 0000000000..6f34e852c9 --- /dev/null +++ b/src/components/syncPlay/ui/settings/SettingsEditor.js @@ -0,0 +1,147 @@ +/** + * Module that displays an editor for changing SyncPlay settings. + * @module components/syncPlay/settings/SettingsEditor + */ + +import { Events } from 'jellyfin-apiclient'; +import SyncPlay from '../../core'; +import { getSetting, setSetting } from '../../core/Settings'; +import dialogHelper from '../../../dialogHelper/dialogHelper'; +import layoutManager from '../../../layoutManager'; +import loading from '../../../loading/loading'; +import toast from '../../../toast/toast'; +import globalize from '../../../../scripts/globalize'; +import { toBoolean, toFloat } from '../../../../scripts/stringUtils'; + +import 'material-design-icons-iconfont'; +import '../../../../elements/emby-input/emby-input'; +import '../../../../elements/emby-select/emby-select'; +import '../../../../elements/emby-button/emby-button'; +import '../../../../elements/emby-button/paper-icon-button-light'; +import '../../../../elements/emby-checkbox/emby-checkbox'; +import '../../../listview/listview.scss'; +import '../../../formdialog.scss'; + +function centerFocus(elem, horiz, on) { + import('../../../../scripts/scrollHelper').then((scrollHelper) => { + const fn = on ? 'on' : 'off'; + scrollHelper.centerFocus[fn](elem, horiz); + }); +} + +/** + * Class that displays an editor for changing SyncPlay settings. + */ +class SettingsEditor { + constructor(apiClient, timeSyncCore, options = {}) { + this.apiClient = apiClient; + this.timeSyncCore = timeSyncCore; + this.options = options; + } + + async embed() { + const dialogOptions = { + removeOnClose: true, + scrollY: true + }; + + if (layoutManager.tv) { + dialogOptions.size = 'fullscreen'; + } else { + dialogOptions.size = 'small'; + } + + this.context = dialogHelper.createDialog(dialogOptions); + this.context.classList.add('formDialog'); + + const { default: editorTemplate } = await import('./editor.html'); + this.context.innerHTML = globalize.translateHtml(editorTemplate, 'core'); + + // Set callbacks for form submission + this.context.querySelector('form').addEventListener('submit', (event) => { + // Disable default form submission + if (event) { + event.preventDefault(); + } + return false; + }); + + this.context.querySelector('.btnSave').addEventListener('click', () => { + this.onSubmit(); + }); + + this.context.querySelector('.btnCancel').addEventListener('click', () => { + dialogHelper.close(this.context); + }); + + await this.initEditor(); + + if (layoutManager.tv) { + centerFocus(this.context.querySelector('.formDialogContent'), false, true); + } + + return dialogHelper.open(this.context).then(() => { + if (layoutManager.tv) { + centerFocus(this.context.querySelector('.formDialogContent'), false, false); + } + + if (this.context.submitted) { + return Promise.resolve(); + } + + return Promise.reject(); + }); + } + + async initEditor() { + const { context } = this; + + context.querySelector('#txtExtraTimeOffset').value = toFloat(getSetting('extraTimeOffset'), 0.0); + context.querySelector('#chkSyncCorrection').checked = toBoolean(getSetting('enableSyncCorrection'), true); + context.querySelector('#txtMinDelaySpeedToSync').value = toFloat(getSetting('minDelaySpeedToSync'), 60.0); + context.querySelector('#txtMaxDelaySpeedToSync').value = toFloat(getSetting('maxDelaySpeedToSync'), 3000.0); + context.querySelector('#txtSpeedToSyncDuration').value = toFloat(getSetting('speedToSyncDuration'), 1000.0); + context.querySelector('#txtMinDelaySkipToSync').value = toFloat(getSetting('minDelaySkipToSync'), 400.0); + context.querySelector('#chkSpeedToSync').checked = toBoolean(getSetting('useSpeedToSync'), true); + context.querySelector('#chkSkipToSync').checked = toBoolean(getSetting('useSkipToSync'), true); + } + + onSubmit() { + this.save(); + dialogHelper.close(this.context); + } + + async save() { + loading.show(); + await this.saveToAppSettings(); + loading.hide(); + toast(globalize.translate('SettingsSaved')); + Events.trigger(this, 'saved'); + } + + async saveToAppSettings() { + const { context } = this; + + const extraTimeOffset = context.querySelector('#txtExtraTimeOffset').value; + const syncCorrection = context.querySelector('#chkSyncCorrection').checked; + const minDelaySpeedToSync = context.querySelector('#txtMinDelaySpeedToSync').value; + const maxDelaySpeedToSync = context.querySelector('#txtMaxDelaySpeedToSync').value; + const speedToSyncDuration = context.querySelector('#txtSpeedToSyncDuration').value; + const minDelaySkipToSync = context.querySelector('#txtMinDelaySkipToSync').value; + const useSpeedToSync = context.querySelector('#chkSpeedToSync').checked; + const useSkipToSync = context.querySelector('#chkSkipToSync').checked; + + setSetting('extraTimeOffset', extraTimeOffset); + setSetting('enableSyncCorrection', syncCorrection); + setSetting('minDelaySpeedToSync', minDelaySpeedToSync); + setSetting('maxDelaySpeedToSync', maxDelaySpeedToSync); + setSetting('speedToSyncDuration', speedToSyncDuration); + setSetting('minDelaySkipToSync', minDelaySkipToSync); + setSetting('useSpeedToSync', useSpeedToSync); + setSetting('useSkipToSync', useSkipToSync); + + Events.trigger(SyncPlay.Manager, 'settings-update'); + } +} + +export default SettingsEditor; diff --git a/src/components/syncPlay/ui/settings/editor.html b/src/components/syncPlay/ui/settings/editor.html new file mode 100644 index 0000000000..6267d9cf0a --- /dev/null +++ b/src/components/syncPlay/ui/settings/editor.html @@ -0,0 +1,75 @@ +