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:
parent
4a5051317b
commit
94c405f08e
20 changed files with 618 additions and 17 deletions
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
|
|
84
src/components/syncPlay/core/Settings.js
Normal file
84
src/components/syncPlay/core/Settings.js
Normal 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;
|
|
@ -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: {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
245
src/components/syncPlay/ui/settings/SettingsEditor.js
Normal file
245
src/components/syncPlay/ui/settings/SettingsEditor.js
Normal 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;
|
29
src/components/syncPlay/ui/settings/advancedTab.html
Normal file
29
src/components/syncPlay/ui/settings/advancedTab.html
Normal 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>
|
21
src/components/syncPlay/ui/settings/editor.html
Normal file
21
src/components/syncPlay/ui/settings/editor.html
Normal 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>
|
29
src/components/syncPlay/ui/settings/localTab.html
Normal file
29
src/components/syncPlay/ui/settings/localTab.html
Normal 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>
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:",
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue