567 lines
21 KiB
TypeScript
567 lines
21 KiB
TypeScript
import CropModePersistence from '../../../common/enums/CropModePersistence.enum';
|
|
import ExtensionMode from '../../../common/enums/ExtensionMode.enum';
|
|
import { SettingsReloadComponent, SettingsReloadFlags, SiteSettingsInterface } from '../../../common/interfaces/SettingsInterface';
|
|
import { _cp } from '../../../common/js/utils';
|
|
import Settings from './Settings';
|
|
import StretchType from '../../../common/enums/StretchType.enum';
|
|
import VideoAlignmentType from '../../../common/enums/VideoAlignmentType.enum';
|
|
import EmbeddedContentSettingsOverridePolicy from '../../../common/enums/EmbeddedContentSettingsOverridePolicy.enum';
|
|
|
|
|
|
export interface GetSiteSettingsOptions {
|
|
site: string,
|
|
isIframe?: boolean,
|
|
parentHostname?: string,
|
|
}
|
|
|
|
/**
|
|
* Contains settings that are currently in effect for a given site. If a certain option
|
|
* doesn't have a value — or if it has 'default' option, SiteSettings.data will contain
|
|
* the value that "default" should stand for.
|
|
*
|
|
* SiteSettings.raw also contains settings object as it actually exist in the config.
|
|
*/
|
|
export class SiteSettings {
|
|
private settings: Settings;
|
|
private _site: string;
|
|
private set site(x: string) {
|
|
this._site = x;
|
|
}
|
|
public get site() {
|
|
return this._site;
|
|
}
|
|
private options: GetSiteSettingsOptions;
|
|
|
|
raw: SiteSettingsInterface; // actual settings
|
|
data: SiteSettingsInterface; // effective settings
|
|
usesSettingsFor: string | undefined;
|
|
temporaryData: SiteSettingsInterface;
|
|
sessionData: SiteSettingsInterface;
|
|
readonly defaultSettings: SiteSettingsInterface;
|
|
|
|
storageChangeSubscriptions: {[x: string]: ((newSiteConf, changes, area) => void)[]} = {};
|
|
|
|
//#region lifecycle
|
|
constructor(settings: Settings, options: GetSiteSettingsOptions) {
|
|
this.options = options;
|
|
this.site = options.site;
|
|
|
|
this.settings = settings;
|
|
this.raw = settings.active.sites[this.site];
|
|
this.defaultSettings = settings.active.sites['@global'];
|
|
|
|
this.compileSettingsObject();
|
|
|
|
// temporary data starts as a copy of real data. This ensures defaults are correct if nothing
|
|
// modifies temporary settings. (Purpose: providing until-reload persistence option)
|
|
this.temporaryData = _cp(this.data);
|
|
|
|
this.sessionData = JSON.parse(sessionStorage.getItem('uw-session-defaults'));
|
|
if (!this.sessionData) {
|
|
this.sessionData = _cp(this.data);
|
|
sessionStorage.setItem('uw-session-defaults', JSON.stringify(this.sessionData));
|
|
}
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {this.storageChangeListener(changes, area)})
|
|
}
|
|
|
|
/**
|
|
* Tries to match websites, even if we're on a different subdomain.
|
|
*
|
|
* Priority chain:
|
|
*
|
|
* START HERE
|
|
* |
|
|
* [ exact hostname match ]
|
|
* | |
|
|
* no yes ————————> return settings for hostname
|
|
* V
|
|
* | matches hostname on |
|
|
* | different subdomain? |
|
|
* | |
|
|
* no yes ————————> return settings for matching subdomain
|
|
* |
|
|
* V
|
|
* return default settings
|
|
*
|
|
*
|
|
* Inside iframes, we also get priorities. We fetch settings for both iframe and the parent page.
|
|
*
|
|
* START HERE
|
|
* |
|
|
* | are there settings for | +——> | does iframe host have |
|
|
* | iframe host? | | | overrideWhenEmbedded set? |
|
|
* | | | | |
|
|
* no yes —————+ no yes —-> return iframe settings
|
|
* V V
|
|
* | does parent host have | | does parent use settings for @global ? |
|
|
* | applyToEmbeddedContent | | |
|
|
* | set to Never? | no yes ——> return iframe settings
|
|
* | | V
|
|
* no no | what's applyToEmbeddedContent |
|
|
* | | | set to on parent host? |
|
|
* V V | | |
|
|
* return parent return default Always UseAsDefault Never
|
|
* settings settings | | |
|
|
* V V V
|
|
* return parent return iframe
|
|
* settings settings
|
|
*
|
|
* @returns
|
|
*/
|
|
private getSettingsForSite(options: GetSiteSettingsOptions): {siteSettings: SiteSettingsInterface, usesSettingsFor: string | undefined} {
|
|
if (!options.site) {
|
|
return {
|
|
siteSettings: this.settings.active.sites['@global'],
|
|
usesSettingsFor: '@global'
|
|
};
|
|
}
|
|
|
|
const defaultSettings = {
|
|
siteSettings: this.settings.active.sites['@global'],
|
|
usesSettingsFor: '@global'
|
|
};
|
|
let siteSettings: {siteSettings: SiteSettingsInterface, usesSettingsFor: string | undefined};
|
|
|
|
if (this.settings.active.sites[options.site]) {
|
|
siteSettings = {
|
|
siteSettings: this.settings.active.sites[options.site],
|
|
usesSettingsFor: undefined
|
|
};
|
|
} else {
|
|
const urlSegments = this.site.split('.').reverse();
|
|
|
|
siteLoop:
|
|
for (const cs in this.settings.active.sites) {
|
|
const configUrlSegments = cs.split('.').reverse();
|
|
|
|
// Match site with wildcard site definitions
|
|
// Also, if definition starts with 'www', match also other subdomains — e.g. if we have a configuration for
|
|
// `www.example.com`, this will also match `example.com`, `subdomain.example.com`, `nested.subdomain.example.com` ...
|
|
if (configUrlSegments[configUrlSegments.length - 1] === '*' || (configUrlSegments[configUrlSegments.length - 1] === 'www')) {
|
|
|
|
for (let i = 0; i < configUrlSegments.length - 1 && i < urlSegments.length; i++) {
|
|
if (configUrlSegments[i] !== urlSegments[i]) {
|
|
continue siteLoop;
|
|
}
|
|
}
|
|
siteSettings = {
|
|
siteSettings: this.settings.active.sites[cs],
|
|
usesSettingsFor: cs
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!options.isIframe) {
|
|
return siteSettings ?? defaultSettings;
|
|
} else {
|
|
const parentSettings = this.getSettingsForSite({site: options.parentHostname});
|
|
|
|
if (siteSettings) {
|
|
return (
|
|
siteSettings.siteSettings.overrideWhenEmbedded || parentSettings.usesSettingsFor === '@global' || (
|
|
parentSettings.siteSettings.applyToEmbeddedContent && parentSettings.siteSettings.applyToEmbeddedContent !== EmbeddedContentSettingsOverridePolicy.Always
|
|
)
|
|
) ? siteSettings : parentSettings;
|
|
} else {
|
|
if (parentSettings.usesSettingsFor !== '@global' && parentSettings.siteSettings.applyToEmbeddedContent === EmbeddedContentSettingsOverridePolicy.Never) {
|
|
return defaultSettings;
|
|
}
|
|
return parentSettings;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merges defaultSettings into site settings where appropriate.
|
|
* Alan pls ensure default settings object follows the correct structure
|
|
*/
|
|
private compileSettingsObject() {
|
|
const {siteSettings, usesSettingsFor} = this.getSettingsForSite(this.options);
|
|
this.data = _cp(siteSettings);
|
|
this.usesSettingsFor = usesSettingsFor;
|
|
|
|
if (!this.data) {
|
|
this.data = _cp(this.defaultSettings);
|
|
return;
|
|
}
|
|
|
|
if (!this.data.defaults) {
|
|
this.data.defaults = _cp(this.defaultSettings.defaults);
|
|
} else {
|
|
// 'undefined' default here means use default
|
|
this.data.defaults.crop = this.data.defaults.crop ?? _cp(this.defaultSettings.defaults.crop);
|
|
|
|
// these can contain default options, but can also be undefined
|
|
if ([undefined, StretchType.Default].includes(this.data.defaults?.stretch?.type)) {
|
|
this.data.defaults.stretch = _cp(this.defaultSettings.defaults.stretch ?? StretchType.NoStretch);
|
|
}
|
|
if (this.data.defaults?.alignment === undefined) { // distinguish between undefined and 0!
|
|
this.data.defaults.alignment = _cp(this.defaultSettings.defaults.alignment ?? {x: VideoAlignmentType.Center, y: VideoAlignmentType.Center});
|
|
} else {
|
|
if (this.data.defaults?.alignment.x === VideoAlignmentType.Default) {
|
|
this.data.defaults.alignment.x = _cp(this.defaultSettings.defaults.alignment.x ?? VideoAlignmentType.Center);
|
|
}
|
|
if (this.data.defaults.alignment.y === VideoAlignmentType.Default) {
|
|
this.data.defaults.alignment.y = _cp(this.defaultSettings.defaults.alignment.y ?? VideoAlignmentType.Center);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const enableSegment of ['enable', 'enableAard', 'enableKeyboard', 'enableUI']) {
|
|
if (!this.data[enableSegment]) {
|
|
this.data[enableSegment] = {};
|
|
}
|
|
for (const environment of ['normal', 'theater', 'fullscreen']) {
|
|
if (!this.data[enableSegment][environment] || this.data[enableSegment][environment] === ExtensionMode.Default) {
|
|
this.data[enableSegment][environment] = _cp(this.defaultSettings[enableSegment][environment]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!this.data.persistCSA || this.data.persistCSA === CropModePersistence.Default) {
|
|
this.data.persistCSA = this.defaultSettings.persistCSA ?? CropModePersistence.Disabled;
|
|
}
|
|
|
|
if (this.data.activeDOMConfig && this.data.DOMConfig) {
|
|
this.data.currentDOMConfig = this.data.DOMConfig[this.data.activeDOMConfig];
|
|
}
|
|
}
|
|
//#endregion
|
|
|
|
//#region events
|
|
/**
|
|
* Updates setting values for current and default site
|
|
* @param changes
|
|
* @param area
|
|
* @returns
|
|
*/
|
|
private storageChangeListener(changes, area) {
|
|
if (!changes.uwSettings) {
|
|
return;
|
|
}
|
|
|
|
const parsedSettings = JSON.parse(changes.uwSettings.newValue);
|
|
this.data = parsedSettings.sites[this.site];
|
|
|
|
// we ignore 'readonly' property this once
|
|
(this as any).defaultSettings = parsedSettings.sites['@global'];
|
|
|
|
this.compileSettingsObject();
|
|
|
|
// trigger any subscriptions on change
|
|
if (parsedSettings._updateFlags) {
|
|
if (parsedSettings._updateFlags?.forSite === this.site) {
|
|
if (parsedSettings._updateFlags?.requireReload === true) {
|
|
for (const key in this.storageChangeSubscriptions) {
|
|
if (!this.storageChangeSubscriptions[key]) {
|
|
continue;
|
|
}
|
|
for (const fn of this.storageChangeSubscriptions[key]) {
|
|
fn(this, changes, area);
|
|
}
|
|
}
|
|
}
|
|
else if (parsedSettings._updateFlags?.requireReload) {
|
|
for (const key of parsedSettings._updateFlags?.requireReload) {
|
|
if (! this.storageChangeSubscriptions[key]) {
|
|
continue;
|
|
}
|
|
|
|
for (const fn of this.storageChangeSubscriptions[key]) {
|
|
fn(this, changes, area);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// we aren't stepping on any other toes by doing this, since everyone
|
|
// gets the first change
|
|
// this.settings.active._updateFlags = undefined;
|
|
// this.settings.saveWithoutReload();
|
|
}
|
|
}
|
|
|
|
subscribeToStorageChange(component: SettingsReloadComponent, fn: (newSiteConf, changes, area) => void) {
|
|
if (!this.storageChangeSubscriptions[component]) {
|
|
this.storageChangeSubscriptions[component] = [];
|
|
}
|
|
this.storageChangeSubscriptions[component].push(fn);
|
|
}
|
|
unsubscribeToStorageChange(component: SettingsReloadComponent, fn: (newSiteConf, changes, area) => void) {
|
|
if (this.storageChangeSubscriptions[component]) {
|
|
this.storageChangeSubscriptions[component] = this.storageChangeSubscriptions[component].filter(x => x != fn);
|
|
}
|
|
}
|
|
//#endregion
|
|
|
|
//#region get shit
|
|
/**
|
|
* Gets custom query selector for player or video, if configuration for it exists, is manually defined, and has querySelectors property.
|
|
* @param element player or video
|
|
* @returns querySelector if possible, undefined otherwise
|
|
*/
|
|
getCustomDOMQuerySelector(element: 'video' | 'player'): string | undefined {
|
|
return this.data.currentDOMConfig?.elements?.[element]?.manual && this.data.currentDOMConfig?.elements?.[element]?.querySelectors || undefined;
|
|
}
|
|
|
|
/**
|
|
* Gets custom element index for player, if configuration for player exists, is manually defined, and has index property defined.
|
|
* NOTE: while querySelector should take priority over index, this function does NOT take that into account.
|
|
* @returns parent element index if possible, undefined otherwise
|
|
*/
|
|
getPlayerIndex(): number | undefined {
|
|
return this.data.currentDOMConfig?.elements?.player?.manual && this.data.currentDOMConfig?.elements?.player?.index
|
|
|| this.data.playerAutoConfig?.modified && this.data.playerAutoConfig?.currentIndex
|
|
|| undefined;
|
|
}
|
|
|
|
/**
|
|
* Gets default crop mode for extension, while taking persistence settings into account
|
|
*/
|
|
getDefaultOption(option: 'crop' | 'stretch' | 'alignment') {
|
|
const persistenceLevel = this.data.persistCSA;
|
|
|
|
switch (persistenceLevel) {
|
|
case CropModePersistence.UntilPageReload:
|
|
return this.temporaryData.defaults[option];
|
|
case CropModePersistence.CurrentSession:
|
|
return this.sessionData.defaults[option];
|
|
case CropModePersistence.Disabled:
|
|
case CropModePersistence.Default:
|
|
case CropModePersistence.Forever:
|
|
default:
|
|
return this.data.defaults[option];
|
|
}
|
|
}
|
|
|
|
private _getEnvironment(isTheater: boolean, isFullscreen: boolean): 'fullscreen' | 'theater' | 'normal' {
|
|
if (isFullscreen) {
|
|
return 'fullscreen';
|
|
}
|
|
if (isTheater) {
|
|
return 'theater';
|
|
}
|
|
return 'normal';
|
|
}
|
|
|
|
/**
|
|
* Is extension allowed to run in current environment
|
|
* @param isTheater
|
|
* @param isFullscreen
|
|
* @returns ExtensionMode
|
|
*/
|
|
isEnabledForEnvironment(isTheater: boolean, isFullscreen: boolean): ExtensionMode {
|
|
const env = this._getEnvironment(isTheater, isFullscreen);
|
|
return this.data.enable[env];
|
|
}
|
|
|
|
/**
|
|
* Is autodetection allowed to run, given current environment
|
|
* @param isTheater
|
|
* @param isFullscreen
|
|
* @returns
|
|
*/
|
|
isAardEnabledForEnvironment(isTheater: boolean, isFullscreen: boolean): ExtensionMode {
|
|
const env = this._getEnvironment(isTheater, isFullscreen);
|
|
return this.data.enableAard[env];
|
|
}
|
|
|
|
/**
|
|
* Returns whether keyboard interactions are enabled in current environment
|
|
* @param isTheater
|
|
* @param isFullscreen
|
|
* @returns
|
|
*/
|
|
isKeyboardEnabledForEnvironment(isTheater: boolean, isFullscreen: boolean): ExtensionMode {
|
|
const env = this._getEnvironment(isTheater, isFullscreen);
|
|
return this.data.enableKeyboard[env];
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region get
|
|
/**
|
|
* TODO: check what this function is really doing — implementation didn't make sense
|
|
* and didn't quite agree with the comment.
|
|
*
|
|
* specifically, this function used to always return currentDOMConfig, and the stuff
|
|
* done inside if block appeared to not be affected. It is also only used in one spot.
|
|
*
|
|
* Gets DOMConfig. If DOMConfig with given name doesn't exist, we create a new one.
|
|
* @param configName We want to fetch this DOM config
|
|
* @param copyFrom If DOMConfig data doesn't exist, we copy things from DOMConfig with
|
|
* this name. If DOMConfig with this name doesn't exist, we copy last
|
|
* active DOMConfig. If that fails, we copy original DOMConfig.
|
|
* @returns Current DOMConfig object for this site
|
|
*/
|
|
getDOMConfig(configName: string, copyFrom?: string) {
|
|
// NOTE: this was added because
|
|
// there are no DOMConfig objects for this site
|
|
if (!this.data.DOMConfig) {
|
|
this.data.DOMConfig = {
|
|
'original': {
|
|
type: undefined,
|
|
elements: {
|
|
player: undefined,
|
|
video: undefined,
|
|
other: undefined,
|
|
},
|
|
},
|
|
'modified': {
|
|
type: undefined,
|
|
elements: {
|
|
player: undefined,
|
|
video: undefined,
|
|
other: undefined,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// TODO: check what's with that and if this can be removed? This if looks rather useless
|
|
if (! this.data.DOMConfig[configName]) {
|
|
this.data.DOMConfig[configName] = _cp(this.data.DOMConfig[copyFrom ?? this.data.activeDOMConfig ?? 'original']);
|
|
}
|
|
return this.data.DOMConfig[configName];
|
|
}
|
|
//#endregion
|
|
|
|
//#region set shit
|
|
/**
|
|
* Returns target site for settings application
|
|
*/
|
|
private getTargetSite() {
|
|
if (!this.usesSettingsFor || this.usesSettingsFor === '@global') {
|
|
return this.site;
|
|
}
|
|
if (this.settings.active.sites[this.usesSettingsFor].applyToEmbeddedContent === EmbeddedContentSettingsOverridePolicy.Always) {
|
|
return this.usesSettingsFor;
|
|
}
|
|
return this.site;
|
|
}
|
|
0
|
|
/**
|
|
* Sets option value.
|
|
* @param optionPath path to value in object notation (dot separated)
|
|
* @param optionValue new value of option
|
|
* @param reload whether we should trigger a reload in components that require it
|
|
*/
|
|
async set(optionPath: string, optionValue: any, options: {reload?: boolean, noSave?: boolean, scripted?: boolean} = {reload: false}) {
|
|
// const targetSite = this.getTargetSite();
|
|
const targetSite = this.site;
|
|
|
|
// if no settings exist for this site, create an empty object.
|
|
// If this function is not being called in response to user actin,
|
|
// create fake settings object.
|
|
if (options.scripted && !this.settings.active.sites[targetSite]) {
|
|
this.settings.active.sites[targetSite] = _cp(this.settings.active.sites['@global']);
|
|
this.settings.active.sites[targetSite].autocreated = true;
|
|
this.settings.active.sites[targetSite].type = 'unknown';
|
|
} else {
|
|
if (!this.settings.active.sites[targetSite] || this.settings.active.sites[targetSite].autocreated) {
|
|
this.settings.active.sites[targetSite] = _cp(this.data);
|
|
this.settings.active.sites[targetSite].type = 'user-defined';
|
|
}
|
|
}
|
|
|
|
const pathParts = optionPath.split('.');
|
|
|
|
if (pathParts.length === 1) {
|
|
this.settings.active.sites[targetSite][optionPath] = optionValue;
|
|
} else {
|
|
let iterator = this.settings.active.sites[targetSite];
|
|
let i;
|
|
let iterated = '';
|
|
|
|
for (i = 0; i < pathParts.length - 1; i++) {
|
|
iterated = `${iterated}.${pathParts[i]}`;
|
|
|
|
if (!iterator[pathParts[i]]) { // some optional paths may still be undefined, even after cloning empty object
|
|
iterator[pathParts[i]] = {};
|
|
}
|
|
iterator = iterator[pathParts[i]];
|
|
}
|
|
iterated = `${iterated}.${pathParts[i]}`
|
|
|
|
iterator[pathParts[i]] = optionValue;
|
|
}
|
|
|
|
if (! options.noSave) {
|
|
if (options.reload) {
|
|
await this.settings.save();
|
|
} else {
|
|
await this.settings.saveWithoutReload();
|
|
this.compileSettingsObject();
|
|
}
|
|
}
|
|
}
|
|
|
|
async setUpdateFlags(flags: SettingsReloadFlags) {
|
|
this.settings.active._updateFlags = {
|
|
requireReload: flags,
|
|
forSite: this.site
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sets temporary option value (for Persistence.UntilReload)
|
|
* @param optionPath path to value in object notation (dot separated)
|
|
* @param optionValue new value of option
|
|
*/
|
|
setTemporary(optionPath: string, optionValue: any) {
|
|
const pathParts = optionPath.split('.');
|
|
|
|
let iterator = this.temporaryData;
|
|
let i;
|
|
for (i = 0; i < pathParts.length - 1; i++) {
|
|
iterator = iterator[pathParts[i]];
|
|
}
|
|
iterator[pathParts[i]] = optionValue;
|
|
}
|
|
|
|
/**
|
|
* Sets option value to sessionStorage (for Persistence.CurrentSession)
|
|
* @param optionPath path to value in object notation (dot separated)
|
|
* @param optionValue new value of option
|
|
*/
|
|
setSession(optionPath: string, optionValue: any) {
|
|
const pathParts = optionPath.split('.');
|
|
|
|
let iterator = this.sessionData;
|
|
let i;
|
|
for (i = 0; i < pathParts.length - 1; i++) {
|
|
iterator = iterator[pathParts[i]];
|
|
}
|
|
iterator[pathParts[i]] = optionValue;
|
|
|
|
sessionStorage.setItem('uw-session-defaults', JSON.stringify(this.sessionData));
|
|
}
|
|
|
|
/**
|
|
* Updates options while accounting for persistence settings
|
|
* @param option
|
|
* @param value
|
|
* @returns
|
|
*/
|
|
async updatePersistentOption(option: 'crop' | 'stretch' | 'alignment', value) {
|
|
const persistenceLevel = this.data.persistCSA;
|
|
switch (persistenceLevel) {
|
|
case CropModePersistence.Disabled:
|
|
return;
|
|
case CropModePersistence.UntilPageReload:
|
|
return this.temporaryData.defaults[option] = value;
|
|
case CropModePersistence.CurrentSession:
|
|
this.sessionData.defaults[option] = value;
|
|
return sessionStorage.setItem('uw-session-defaults', JSON.stringify(this.sessionData));
|
|
case CropModePersistence.Forever:
|
|
return this.set(`defaults.${option}`, value); // async, but we don't care in this case IIRC
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
//#endregion
|
|
|
|
}
|