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

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

@ -77,6 +77,7 @@ progress[aria-valuenow]::before {
height: 4em; height: 4em;
} }
.controlGroupButton,
a[data-role=button] { a[data-role=button] {
background: #292929 !important; background: #292929 !important;
background-clip: padding-box; background-clip: padding-box;
@ -93,6 +94,7 @@ a[data-role=button] {
text-decoration: none !important; text-decoration: none !important;
} }
div[data-role=controlgroup] .controlGroupButton,
div[data-role=controlgroup] a[data-role=button] { div[data-role=controlgroup] a[data-role=button] {
display: inline-block !important; display: inline-block !important;
margin: 0 !important; margin: 0 !important;
@ -102,6 +104,7 @@ div[data-role=controlgroup] a[data-role=button] {
border-radius: 0; border-radius: 0;
} }
div[data-role=controlgroup] .controlGroupButton:first-child,
div[data-role=controlgroup] a[data-role=button]:first-child { div[data-role=controlgroup] a[data-role=button]:first-child {
-webkit-border-bottom-left-radius: 0.3125em; -webkit-border-bottom-left-radius: 0.3125em;
border-bottom-left-radius: 0.3125em; border-bottom-left-radius: 0.3125em;
@ -109,6 +112,7 @@ div[data-role=controlgroup] a[data-role=button]:first-child {
border-top-left-radius: 0.3125em; border-top-left-radius: 0.3125em;
} }
div[data-role=controlgroup] .controlGroupButton:last-child,
div[data-role=controlgroup] a[data-role=button]:last-child { div[data-role=controlgroup] a[data-role=button]:last-child {
-webkit-border-bottom-right-radius: 0.3125em; -webkit-border-bottom-right-radius: 0.3125em;
border-bottom-right-radius: 0.3125em; border-bottom-right-radius: 0.3125em;
@ -116,11 +120,13 @@ div[data-role=controlgroup] a[data-role=button]:last-child {
border-top-right-radius: 0.3125em; border-top-right-radius: 0.3125em;
} }
div[data-role=controlgroup] .controlGroupButton + .controlGroupButton,
div[data-role=controlgroup] a[data-role=button] + a[data-role=button] { div[data-role=controlgroup] a[data-role=button] + a[data-role=button] {
border-left-width: 0 !important; border-left-width: 0 !important;
margin: 0 0 0 -0.4em !important; margin: 0 0 0 -0.4em !important;
} }
div[data-role=controlgroup] .controlGroupButton.ui-btn-active,
div[data-role=controlgroup] a.ui-btn-active { div[data-role=controlgroup] a.ui-btn-active {
background: #00a4dc !important; background: #00a4dc !important;
color: #292929 !important; color: #292929 !important;

View file

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

View file

@ -5,6 +5,7 @@
import { Events } from 'jellyfin-apiclient'; import { Events } from 'jellyfin-apiclient';
import * as Helper from './Helper'; import * as Helper from './Helper';
import Settings from './Settings';
/** /**
* Class that manages the playback of SyncPlay. * 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.lastCommand = null; // Last scheduled playback command, might not be the latest one.
this.scheduledCommandTimeout = null; this.scheduledCommandTimeout = null;
this.syncTimeout = null; this.syncTimeout = null;
this.loadPreferences();
} }
/** /**
@ -35,26 +38,35 @@ class PlaybackCore {
this.manager = syncPlayManager; this.manager = syncPlayManager;
this.timeSyncCore = syncPlayManager.getTimeSyncCore(); 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. // 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. // 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. // 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. // 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. // Whether SpeedToSync should be used.
this.useSpeedToSync = true; this.useSpeedToSync = Settings.getBool('useSpeedToSync', true);
// Whether SkipToSync should be used. // Whether SkipToSync should be used.
this.useSkipToSync = true; this.useSkipToSync = Settings.getBool('useSkipToSync', true);
// Whether sync correction during playback is active. // 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. // Diff might be caused by the player internally starting the playback.
const diffMillis = (serverPositionTicks - currentPositionTicks) / Helper.TicksPerMillisecond; 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; this.playbackDiffMillis = diffMillis;
Events.trigger(this.manager, 'playback-diff', [this.playbackDiffMillis]);
// Avoid overloading the browser. // Avoid overloading the browser.
const elapsed = currentTime - this.lastSyncTime; const elapsed = currentTime - this.lastSyncTime;
@ -536,22 +553,22 @@ class PlaybackCore {
const playerWrapper = this.manager.getPlayerWrapper(); const playerWrapper = this.manager.getPlayerWrapper();
if (this.syncEnabled && this.enableSyncCorrection) { if (this.syncEnabled && this.enableSyncCorrection) {
const absDiffMillis = Math.abs(diffMillis); const absDiffMillis = Math.abs(targetPlaybackDiff);
// TODO: SpeedToSync sounds bad on songs. // TODO: SpeedToSync sounds bad on songs.
// TODO: SpeedToSync is failing on Safari (Mojave); even if playbackRate is supported, some delay seems to exist. // 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. // 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) { if (playerWrapper.hasPlaybackRate() && this.useSpeedToSync && absDiffMillis >= this.minDelaySpeedToSync && absDiffMillis < this.maxDelaySpeedToSync) {
// Fix negative speed when client is ahead of time more than speedToSyncTime. // Fix negative speed when client is ahead of time more than speedToSyncTime.
const MinSpeed = 0.2; const MinSpeed = 0.2;
if (diffMillis <= -speedToSyncTime * MinSpeed) { if (targetPlaybackDiff <= -speedToSyncTime * MinSpeed) {
speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed); speedToSyncTime = Math.abs(targetPlaybackDiff) / (1.0 - MinSpeed);
} }
// SpeedToSync strategy. // SpeedToSync strategy.
const speed = 1 + diffMillis / speedToSyncTime; const speed = 1 + targetPlaybackDiff / speedToSyncTime;
if (speed <= 0) { 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); 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 * as Helper from './Helper';
import Settings from './Settings';
import ManagerClass from './Manager'; import ManagerClass from './Manager';
import PlayerFactoryClass from './players/PlayerFactory'; import PlayerFactoryClass from './players/PlayerFactory';
import GenericPlayer from './players/GenericPlayer'; import GenericPlayer from './players/GenericPlayer';
@ -8,6 +9,7 @@ const Manager = new ManagerClass(PlayerFactory);
export default { export default {
Helper, Helper,
Settings,
Manager, Manager,
PlayerFactory, PlayerFactory,
Players: { Players: {

View file

@ -4,6 +4,7 @@
*/ */
import { Events } from 'jellyfin-apiclient'; import { Events } from 'jellyfin-apiclient';
import Settings from '../Settings';
import TimeSyncServer from './TimeSyncServer'; import TimeSyncServer from './TimeSyncServer';
/** /**
@ -13,6 +14,9 @@ class TimeSyncCore {
constructor() { constructor() {
this.manager = null; this.manager = null;
this.timeSyncServer = 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.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(); 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. * Gets the display name of the selected device for time sync.
* @returns {string} The display name. * @returns {string} The display name.
@ -54,7 +88,8 @@ class TimeSyncCore {
* @returns {Date} Local time. * @returns {Date} Local time.
*/ */
remoteDateToLocal(remote) { 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. * @returns {Date} Server time.
*/ */
localDateToRemote(local) { 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. * @returns {number} The time offset.
*/ */
getTimeOffset() { 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);
} }
} }

View file

@ -1,5 +1,6 @@
import { Events } from 'jellyfin-apiclient'; import { Events } from 'jellyfin-apiclient';
import SyncPlay from '../core'; import SyncPlay from '../core';
import SyncPlaySettingsEditor from './settings/SettingsEditor';
import loading from '../../loading/loading'; import loading from '../../loading/loading';
import toast from '../../toast/toast'; import toast from '../../toast/toast';
import actionsheet from '../../actionSheet/actionSheet'; import actionsheet from '../../actionSheet/actionSheet';
@ -120,6 +121,14 @@ class GroupSelectionMenu {
}); });
} }
menuItems.push({
name: globalize.translate('Settings'),
icon: 'video_settings',
id: 'settings',
selected: false,
secondaryText: globalize.translate('LabelSyncPlaySettingsDescription')
});
menuItems.push({ menuItems.push({
name: globalize.translate('LabelSyncPlayLeaveGroup'), name: globalize.translate('LabelSyncPlayLeaveGroup'),
icon: 'meeting_room', icon: 'meeting_room',
@ -144,6 +153,10 @@ class GroupSelectionMenu {
SyncPlay.Manager.haltGroupPlayback(apiClient); SyncPlay.Manager.haltGroupPlayback(apiClient);
} else if (id == 'leave-group') { } else if (id == 'leave-group') {
apiClient.leaveSyncPlayGroup(); apiClient.leaveSyncPlayGroup();
} else if (id == 'settings') {
new SyncPlaySettingsEditor(apiClient, SyncPlay.Manager.getTimeSyncCore(), {
groupInfo: groupInfo
});
} }
}).catch((error) => { }).catch((error) => {
console.error('SyncPlay: unexpected error showing group menu:', error); console.error('SyncPlay: unexpected error showing group menu:', error);

View file

@ -0,0 +1,245 @@
/**
* Module that displays an editor for changing SyncPlay settings.
* @module components/syncPlay/settings/SettingsEditor
*/
import { Events } from 'jellyfin-apiclient';
import SyncPlay from '../../core';
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 '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;
this.tabNames = [];
this.tabs = {};
this.embed();
Events.on(this.timeSyncCore, 'refresh-devices', (event) => {
this.refreshTimeSyncDevices();
});
Events.on(this.timeSyncCore, 'time-sync-server-update', (event) => {
this.refreshTimeSyncDevices();
});
}
insertBefore(newNode, existingNode) {
existingNode.parentNode.insertBefore(newNode, existingNode);
}
addTab(name, tab) {
this.tabNames.push(name);
this.tabs[name] = tab;
}
showTab(tabName) {
this.tabNames.forEach(id => {
this.tabs[id].style.display = 'none';
this.context.querySelector('#show-' + id).classList.remove('ui-btn-active');
});
const tab = this.tabs[tabName];
if (tab) {
tab.style.display = 'block';
this.context.querySelector('#show-' + tabName).classList.add('ui-btn-active');
}
}
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');
const footer = this.context.querySelector('#footer');
// Create tabs
const { default: localTabTemplate } = await import('./localTab.html');
const localTab = this.translateTemplate(localTabTemplate);
const { default: advancedTabTemplate } = await import('./advancedTab.html');
const advancedTab = this.translateTemplate(advancedTabTemplate);
this.insertBefore(localTab, footer);
this.insertBefore(advancedTab, footer);
// Switch tabs using nav
this.addTab('localTab', localTab);
this.addTab('advancedTab', advancedTab);
this.showTab('localTab');
const tabButtons = this.context.querySelectorAll('.controlGroupButton');
tabButtons.forEach(button => {
button.addEventListener('click', (event) => {
const tabName = event.target.getAttribute('data-showTab');
if (tabName) {
this.showTab(tabName);
}
});
});
// 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 = SyncPlay.Settings.getFloat('extraTimeOffset', 0.0);
context.querySelector('#chkSyncCorrection').checked = SyncPlay.Settings.getBool('enableSyncCorrection', true);
context.querySelector('#txtMinDelaySpeedToSync').value = SyncPlay.Settings.getFloat('minDelaySpeedToSync', 60.0);
context.querySelector('#txtMaxDelaySpeedToSync').value = SyncPlay.Settings.getFloat('maxDelaySpeedToSync', 3000.0);
context.querySelector('#txtSpeedToSyncDuration').value = SyncPlay.Settings.getFloat('speedToSyncDuration', 1000.0);
context.querySelector('#txtMinDelaySkipToSync').value = SyncPlay.Settings.getFloat('minDelaySkipToSync', 400.0);
context.querySelector('#chkSpeedToSync').checked = SyncPlay.Settings.getBool('useSpeedToSync', true);
context.querySelector('#chkSkipToSync').checked = SyncPlay.Settings.getBool('useSkipToSync', true);
this.refreshTimeSyncDevices();
const timeSyncSelect = context.querySelector('#selectTimeSync');
timeSyncSelect.value = this.timeSyncCore.getActiveDevice();
this.timeSyncSelectedValue = timeSyncSelect.value;
timeSyncSelect.addEventListener('change', () => {
this.timeSyncSelectedValue = timeSyncSelect.value;
});
}
refreshTimeSyncDevices() {
const { context } = this;
const timeSyncSelect = context.querySelector('#selectTimeSync');
const devices = this.timeSyncCore.getDevices();
timeSyncSelect.innerHTML = devices.map(device => {
return `<option value="${device.id}">${device.name} (time offset: ${device.timeOffset} ms; ping: ${device.ping} ms)</option>`;
}).join('');
timeSyncSelect.value = this.timeSyncSelectedValue;
}
/**
* @param {string} html HTML string representing a single element.
* @return {Element} The element.
*/
htmlToElement(html) {
const template = document.createElement('template');
html = html.trim(); // Avoid returning a text node of whitespace.
template.innerHTML = html;
return template.content.firstChild;
}
translateTemplate(template) {
const translatedTemplate = globalize.translateHtml(template, 'core');
return this.htmlToElement(translatedTemplate);
}
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 timeSyncDevice = context.querySelector('#selectTimeSync').value;
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;
SyncPlay.Settings.set('timeSyncDevice', timeSyncDevice);
SyncPlay.Settings.set('extraTimeOffset', extraTimeOffset);
SyncPlay.Settings.set('enableSyncCorrection', syncCorrection);
SyncPlay.Settings.set('minDelaySpeedToSync', minDelaySpeedToSync);
SyncPlay.Settings.set('maxDelaySpeedToSync', maxDelaySpeedToSync);
SyncPlay.Settings.set('speedToSyncDuration', speedToSyncDuration);
SyncPlay.Settings.set('minDelaySkipToSync', minDelaySkipToSync);
SyncPlay.Settings.set('useSpeedToSync', useSpeedToSync);
SyncPlay.Settings.set('useSkipToSync', useSkipToSync);
Events.trigger(SyncPlay.Settings, 'update');
}
}
export default SettingsEditor;

View file

@ -0,0 +1,29 @@
<form style="margin: auto;">
<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>
<h2 class="sectionTitle">${HeaderSyncPlayPlaybackSettings}</h2>
<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>
<div class="inputContainer inputContainer-withDescription">
<input type="number" is="emby-input" id="txtMinDelaySkipToSync" pattern="[0-9]*"
label="${LabelSyncPlaySettingsMinDelaySkipToSync}" />
<div class="fieldDescription">${LabelSyncPlaySettingsMinDelaySkipToSyncHelp}</div>
</div>
</form>

View file

@ -0,0 +1,21 @@
<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">
<div data-role="controlgroup" data-type="horizontal" class="localnav" data-mini="true">
<button id="show-localTab" class="controlGroupButton" is="emby-button"
data-showTab="localTab">${HeaderSyncPlayLocalSettings}</button>
<button id="show-advancedTab" class="controlGroupButton" is="emby-button"
data-showTab="advancedTab">${HeaderSyncPlayAdvancedSettings}</button>
</div>
<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,29 @@
<form style="margin: auto;">
<h2 class="sectionTitle">${HeaderSyncPlayTimeSyncSettings}</h2>
<div class="selectContainer">
<select id="selectTimeSync" is="emby-select" label="${LabelSyncPlaySettingsTimeSync}"></select>
<div class="fieldDescription">${LabelSyncPlaySettingsTimeSyncHelp}</div>
</div>
<h2 class="sectionTitle">${HeaderSyncPlayPlaybackSettings}</h2>
<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>
<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="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkSkipToSync" />
<span>${LabelSyncPlaySettingsSkipToSync}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">${LabelSyncPlaySettingsSkipToSyncHelp}</div>
</div>
</form>

View file

@ -65,7 +65,9 @@ import 'webcomponents.js/webcomponents-lite';
const uncheckedHtml = '<span class="material-icons checkboxIcon checkboxIcon-unchecked ' + uncheckedIcon + '"></span>'; const uncheckedHtml = '<span class="material-icons checkboxIcon checkboxIcon-unchecked ' + uncheckedIcon + '"></span>';
labelElement.insertAdjacentHTML('beforeend', '<span class="' + outlineClass + '">' + checkHtml + uncheckedHtml + '</span>'); labelElement.insertAdjacentHTML('beforeend', '<span class="' + outlineClass + '">' + checkHtml + uncheckedHtml + '</span>');
labelTextElement.classList.add('checkboxLabel'); if (labelTextElement) {
labelTextElement.classList.add('checkboxLabel');
}
this.addEventListener('keydown', onKeyDown); this.addEventListener('keydown', onKeyDown);

View file

@ -22,6 +22,10 @@
display: flex; display: flex;
} }
.checkboxContainer-noText {
margin-bottom: 0;
}
.checkboxListContainer { .checkboxListContainer {
margin-bottom: 1.8em; margin-bottom: 1.8em;
} }
@ -63,6 +67,10 @@
justify-content: center; justify-content: center;
} }
.checkboxContainer-noText .checkboxOutline {
top: auto;
}
.checkboxIcon { .checkboxIcon {
font-size: 1.6em; font-size: 1.6em;
color: #fff; color: #fff;
@ -73,16 +81,19 @@
display: none; display: none;
} }
.emby-checkbox:checked + .checkboxOutline > .checkboxIcon-checked,
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked { .emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked {
/* background color set by theme */ /* background color set by theme */
display: flex !important; display: flex !important;
} }
.emby-checkbox:checked + .checkboxOutline > .checkboxIcon-unchecked,
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-unchecked { .emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-unchecked {
/* background color set by theme */ /* background color set by theme */
display: none !important; display: none !important;
} }
.emby-checkbox:checked[disabled] + .checkboxOutline > .checkboxIcon,
.emby-checkbox:checked[disabled] + span + .checkboxOutline > .checkboxIcon { .emby-checkbox:checked[disabled] + span + .checkboxOutline > .checkboxIcon {
background-color: rgba(0, 0, 0, 0.26); background-color: rgba(0, 0, 0, 0.26);
} }

View file

@ -452,6 +452,12 @@
"HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.", "HeaderSubtitleProfilesHelp": "Subtitle profiles describe the subtitle formats supported by the device.",
"HeaderSyncPlayEnabled": "SyncPlay enabled", "HeaderSyncPlayEnabled": "SyncPlay enabled",
"HeaderSyncPlaySelectGroup": "Join a group", "HeaderSyncPlaySelectGroup": "Join a group",
"HeaderSyncPlaySettings": "SyncPlay Settings",
"HeaderSyncPlayGroupSettings": "Group",
"HeaderSyncPlayAdvancedSettings": "Advanced",
"HeaderSyncPlayPlaybackSettings": "Playback",
"HeaderSyncPlayLocalSettings": "Local",
"HeaderSyncPlayTimeSyncSettings": "Time sync",
"HeaderSystemDlnaProfiles": "System Profiles", "HeaderSystemDlnaProfiles": "System Profiles",
"HeaderTaskTriggers": "Task Triggers", "HeaderTaskTriggers": "Task Triggers",
"HeaderThisUserIsCurrentlyDisabled": "This user is currently disabled", "HeaderThisUserIsCurrentlyDisabled": "This user is currently disabled",
@ -866,6 +872,28 @@
"LabelSyncPlaySyncMethod": "Sync method:", "LabelSyncPlaySyncMethod": "Sync method:",
"LabelSyncPlayTimeSyncDevice": "Time syncing with:", "LabelSyncPlayTimeSyncDevice": "Time syncing with:",
"LabelSyncPlayTimeSyncOffset": "Time offset:", "LabelSyncPlayTimeSyncOffset": "Time offset:",
"LabelSyncPlaySettingsDescription": "Change SyncPlay preferences",
"LabelSyncPlaySettingsGroupName": "Group name:",
"LabelSyncPlaySettingsGroupNameHelp": "Change the group's name.",
"LabelSyncPlaySettingsTimeSync": "Device:",
"LabelSyncPlaySettingsTimeSyncHelp": "Pick the device to time sync with. Picking a local peer ensures less noticeable delay between devices on the same network.",
"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.",
"LabelSyncPlaySettingsCreateGroup": "Create group",
"LabelTag": "Tag:", "LabelTag": "Tag:",
"LabelTagline": "Tagline:", "LabelTagline": "Tagline:",
"LabelTextBackgroundColor": "Text background color:", "LabelTextBackgroundColor": "Text background color:",

View file

@ -294,6 +294,14 @@ html {
border: 0.07em solid rgba(0, 0, 0, 0.158); border: 0.07em solid rgba(0, 0, 0, 0.158);
} }
.emby-checkbox:focus + .checkboxOutline {
border-color: #fff;
}
.emby-checkbox:checked + .checkboxOutline {
background-color: #00a4dc;
}
.emby-checkbox:checked + span + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline,
.emby-select-withcolor:focus { .emby-select-withcolor:focus {
border-color: #00a4dc; border-color: #00a4dc;
@ -321,6 +329,7 @@ html {
margin: 0.4rem 0.5rem 0.4rem 0.5rem; margin: 0.4rem 0.5rem 0.4rem 0.5rem;
} }
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline { .emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }

View file

@ -308,6 +308,14 @@ html {
color: #fff !important; color: #fff !important;
} }
.emby-checkbox:focus + .checkboxOutline {
border-color: #fff;
}
.emby-checkbox:checked + .checkboxOutline {
background-color: #00a4dc;
}
.emby-checkbox:checked + span + .checkboxOutline { .emby-checkbox:checked + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }
@ -321,6 +329,7 @@ html {
background-color: #00a4dc; background-color: #00a4dc;
} }
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline { .emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }

View file

@ -289,6 +289,14 @@ html {
color: #fff !important; color: #fff !important;
} }
.emby-checkbox:focus + .checkboxOutline {
border-color: #fff;
}
.emby-checkbox:checked + .checkboxOutline {
background-color: #00a4dc;
}
.emby-checkbox:checked + span + .checkboxOutline { .emby-checkbox:checked + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }
@ -302,6 +310,7 @@ html {
background-color: #00a4dc; background-color: #00a4dc;
} }
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline { .emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }

View file

@ -293,6 +293,14 @@ html {
border: 0.07em solid rgba(0, 0, 0, 0.158); border: 0.07em solid rgba(0, 0, 0, 0.158);
} }
.emby-checkbox:focus + .checkboxOutline {
border-color: #000;
}
.emby-checkbox:checked + .checkboxOutline {
background-color: #00a4dc;
}
.emby-checkbox:checked + span + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline,
.emby-select-withcolor:focus { .emby-select-withcolor:focus {
border-color: #00a4dc; border-color: #00a4dc;
@ -307,6 +315,7 @@ html {
background-color: #00a4dc; background-color: #00a4dc;
} }
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline { .emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }

View file

@ -105,11 +105,13 @@ progress::-webkit-progress-value {
background: #ff77f1; background: #ff77f1;
} }
div[data-role=controlgroup] .controlGroupButton.ui-btn-active,
div[data-role=controlgroup] a.ui-btn-active { div[data-role=controlgroup] a.ui-btn-active {
background: #55828b !important; background: #55828b !important;
color: #e1e5f2 !important; color: #e1e5f2 !important;
} }
.controlGroupButton,
a[data-role=button] { a[data-role=button] {
background: rgba(2, 43, 58, 0.521) !important; background: rgba(2, 43, 58, 0.521) !important;
} }
@ -391,11 +393,21 @@ a[data-role=button] {
color: #fff !important; color: #fff !important;
} }
.emby-checkbox:focus + .checkboxOutline {
border-color: #ff77f1;
}
.emby-checkbox:checked + .checkboxOutline {
background-color: #030322;
border: 0.14em solid rgb(72, 195, 200);
}
.emby-checkbox:checked + span + .checkboxOutline { .emby-checkbox:checked + span + .checkboxOutline {
background-color: #030322; background-color: #030322;
border: 0.14em solid rgb(72, 195, 200); border: 0.14em solid rgb(72, 195, 200);
} }
.emby-checkbox:checked + .checkboxOutline > .minimalCheckboxIcon-checked,
.emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked { .emby-checkbox:checked + span + .checkboxOutline > .checkboxIcon-checked {
color: rgb(12, 232, 214); color: rgb(12, 232, 214);
} }
@ -404,6 +416,7 @@ a[data-role=button] {
border-color: #ff77f1; border-color: #ff77f1;
} }
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline { .emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
border: 0.14em solid #ff77f1; border: 0.14em solid #ff77f1;
} }

View file

@ -276,6 +276,14 @@ html {
border: 0.07em solid rgba(255, 255, 255, 0.135); border: 0.07em solid rgba(255, 255, 255, 0.135);
} }
.emby-checkbox:focus + .checkboxOutline {
border-color: #fff;
}
.emby-checkbox:checked + .checkboxOutline {
background-color: #00a4dc;
}
.emby-checkbox:checked + span + .checkboxOutline, .emby-checkbox:checked + span + .checkboxOutline,
.emby-select-withcolor:focus { .emby-select-withcolor:focus {
border-color: #00a4dc; border-color: #00a4dc;
@ -290,6 +298,7 @@ html {
background-color: #00a4dc; background-color: #00a4dc;
} }
.emby-checkbox:focus:not(:checked) + .checkboxOutline,
.emby-checkbox:focus:not(:checked) + span + .checkboxOutline { .emby-checkbox:focus:not(:checked) + span + .checkboxOutline {
border-color: #00a4dc; border-color: #00a4dc;
} }