1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #2204 from OancaAndrei/syncplay-settings

This commit is contained in:
Bill Thornton 2021-09-06 12:29:26 -04:00 committed by GitHub
commit 6dffc58e29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 407 additions and 18 deletions

View file

@ -60,6 +60,7 @@
}
.layout-tv .formDialogFooter {
position: relative;
align-items: center;
justify-content: center;
flex-wrap: wrap;

View file

@ -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;

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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) => {
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) => {
if (error) {
console.error('SyncPlay: unexpected error showing group menu:', error);
}
});
loading.hide();

View file

@ -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;

View file

@ -0,0 +1,75 @@
<div class="formDialogHeader">
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1">
<span class="material-icons arrow_back"></span>
</button>
<h3 class="formDialogHeaderTitle">${HeaderSyncPlaySettings}</h3>
</div>
<div class="formDialogContent smoothScrollY">
<div class="dialogContentInner dialog-content-centered">
<form style="margin: auto;">
<h2 class="sectionTitle">${HeaderSyncPlayPlaybackSettings}</h2>
<!-- Sync Correction Setting -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSyncCorrection" />
<span>${LabelSyncPlaySettingsSyncCorrection}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSyncCorrectionHelp}</div>
</div>
<!-- SpeedToSync Settings -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSpeedToSync" />
<span>${LabelSyncPlaySettingsSpeedToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySpeedToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMaxDelaySpeedToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMaxDelaySpeedToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtSpeedToSyncDuration" pattern="[0-9]*"
label="${LabelSyncPlaySettingsSpeedToSyncDuration}" />
<div class="fieldDescription">${LabelSyncPlaySettingsSpeedToSyncDurationHelp}</div>
</div>
<!-- SkipToSync Settings -->
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSkipToSync" />
<span>${LabelSyncPlaySettingsSkipToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSkipToSyncHelp}</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
</div>
<!-- Time Settings -->
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtExtraTimeOffset" pattern="[0-9]*"
label="${LabelSyncPlaySettingsExtraTimeOffset}" />
<div class="fieldDescription">${LabelSyncPlaySettingsExtraTimeOffsetHelp}</div>
</div>
</form>
<div class="formDialogFooter" id="footer">
<button is="emby-button" type="submit" class="raised button-submit block btnSave formDialogFooterItem">
<span id="saveButtonText">${Save}</span>
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,27 @@
/**
* Gets the value of a string as boolean.
* @param {string} name The value as a string.
* @param {boolean} defaultValue The default value if the string is invalid.
* @returns {boolean} The value.
*/
export function toBoolean(value, defaultValue = false) {
if (value !== 'true' && value !== 'false') {
return defaultValue;
} else {
return value !== 'false';
}
}
/**
* Gets the value of a string as float number.
* @param {string} value The value as a string.
* @param {number} defaultValue The default value if the string is invalid.
* @returns {number} The value.
*/
export function toFloat(value, defaultValue = 0) {
if (value === null || value === '' || isNaN(value)) {
return defaultValue;
} else {
return parseFloat(value);
}
}

View file

@ -455,6 +455,9 @@
"HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.",
"HeaderSyncPlayEnabled": "SyncPlay enabled",
"HeaderSyncPlaySelectGroup": "Join a group",
"HeaderSyncPlaySettings": "SyncPlay Settings",
"HeaderSyncPlayPlaybackSettings": "Playback",
"HeaderSyncPlayTimeSyncSettings": "Time sync",
"HeaderSystemDlnaProfiles": "System Profiles",
"HeaderTaskTriggers": "Task Triggers",
"HeaderThisUserIsCurrentlyDisabled": "This user is currently disabled",
@ -869,6 +872,23 @@
"LabelSyncPlaySyncMethod": "Sync method:",
"LabelSyncPlayTimeSyncDevice": "Time syncing with:",
"LabelSyncPlayTimeSyncOffset": "Time offset:",
"LabelSyncPlaySettingsDescription": "Change SyncPlay preferences",
"LabelSyncPlaySettingsExtraTimeOffset": "Extra time offset:",
"LabelSyncPlaySettingsExtraTimeOffsetHelp": "Manually adjust time offset with selected device for time sync. Tweak with care.",
"LabelSyncPlaySettingsSyncCorrection": "Sync Correction",
"LabelSyncPlaySettingsSyncCorrectionHelp": "Enable active syncing of playback by either speeding up the media or by seeking to the estimated position. Disable this in case of heavy stuttering.",
"LabelSyncPlaySettingsMinDelaySpeedToSync": "SpeedToSync minimum delay:",
"LabelSyncPlaySettingsMinDelaySpeedToSyncHelp": "Minimum playback delay after which SpeedToSync attempts to correct playback position.",
"LabelSyncPlaySettingsMaxDelaySpeedToSync": "SpeedToSync maximum delay:",
"LabelSyncPlaySettingsMaxDelaySpeedToSyncHelp": "Maximum playback delay after which SkipToSync is used instead of SpeedToSync.",
"LabelSyncPlaySettingsSpeedToSyncDuration": "SpeedToSync duration:",
"LabelSyncPlaySettingsSpeedToSyncDurationHelp": "Amount of time used by SpeedToSync to correct playback position.",
"LabelSyncPlaySettingsMinDelaySkipToSync": "SkipToSync minimum delay:",
"LabelSyncPlaySettingsMinDelaySkipToSyncHelp": "Minimum playback delay after which SkipToSync attempts to correct playback position.",
"LabelSyncPlaySettingsSpeedToSync": "Enable SpeedToSync",
"LabelSyncPlaySettingsSpeedToSyncHelp": "Sync correction method that consist in speeding up the playback. Sync Correction must be enabled.",
"LabelSyncPlaySettingsSkipToSync": "Enable SkipToSync",
"LabelSyncPlaySettingsSkipToSyncHelp": "Sync correction method that consist in seeking to the estimated position. Sync Correction must be enabled.",
"LabelTag": "Tag:",
"LabelTagline": "Tagline:",
"LabelTextBackgroundColor": "Text background color:",

View file

@ -159,11 +159,6 @@ html {
background-color: #f57f17;
}
.formDialogFooter:not(.formDialogFooter-clear) {
border-top: 1px solid #ddd;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.cardText-secondary,
.fieldDescription,
.guide-programNameCaret,
@ -203,6 +198,11 @@ html {
color: rgba(255, 255, 255, 0.87);
}
.formDialogFooter:not(.formDialogFooter-clear) {
border-top: 1px solid #ddd;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.appfooter,
.formDialogFooter:not(.formDialogFooter-clear),
.playlistSectionButton {
@ -214,6 +214,12 @@ html {
background: linear-gradient(to right, #bcbcbc, #a7b4b7, #beb5a5, #adbec2, #b9c7cb);
}
.layout-tv .formDialogFooter:not(.formDialogFooter-clear) {
border: none;
color: initial;
background: none;
}
.nowPlayingBarSecondaryText {
color: #999;
}

View file

@ -139,6 +139,10 @@ html {
background-color: rgba(0, 0, 0, 0.5);
}
.layout-tv .formDialogFooter:not(.formDialogFooter-clear) {
background-color: transparent;
}
.defaultCardBackground1 {
background-color: #213440;
}

View file

@ -127,6 +127,10 @@ html {
background-color: #202020;
}
.layout-tv .formDialogFooter:not(.formDialogFooter-clear) {
background-color: transparent;
}
.defaultCardBackground1 {
background-color: #00455c;
}

View file

@ -170,6 +170,11 @@ html {
color: inherit;
}
.layout-tv .formDialogFooter:not(.formDialogFooter-clear) {
background-color: transparent;
border: none;
}
.cardText-secondary,
.fieldDescription,
.guide-programNameCaret,

View file

@ -105,11 +105,13 @@ progress::-webkit-progress-value {
background: #ff77f1;
}
div[data-role=controlgroup] .controlGroupButton.ui-btn-active,
div[data-role=controlgroup] a.ui-btn-active {
background: #55828b !important;
color: #e1e5f2 !important;
}
.controlGroupButton,
a[data-role=button] {
background: rgba(2, 43, 58, 0.521) !important;
}
@ -221,6 +223,11 @@ a[data-role=button] {
border-radius: 0.8em;
}
.layout-tv .formDialogFooter:not(.formDialogFooter-clear) {
background-color: transparent;
border-radius: 0;
}
.cardOverlayContainer {
border-radius: 0.8em;
}

View file

@ -193,6 +193,11 @@ html {
color: rgba(255, 255, 255, 0.78);
}
.layout-tv .formDialogFooter:not(.formDialogFooter-clear) {
background: transparent;
color: initial;
}
.itemSelectionPanel {
border: 1px solid #00a4dc;
}