diff --git a/package.json b/package.json index d73e8aa8..bc0d7f20 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.20.91", + "version": "4.21.0", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", diff --git a/src/modules/chat/actions/index.jsx b/src/modules/chat/actions/index.jsx index 4c3304fa..733f0815 100644 --- a/src/modules/chat/actions/index.jsx +++ b/src/modules/chat/actions/index.jsx @@ -33,13 +33,8 @@ export default class Actions extends Module { title: 'Action Size', description: "How tall actions should be, in pixels. This may be affected by your browser's zoom and font size settings.", component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val <= 0 ) - return 16; - - return val; - } + process: 'to_int', + bounds: [1] } }); diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index 70cd3e87..5c07656d 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -143,13 +143,8 @@ export default class Chat extends Module { title: 'Timestamp Font Size', description: 'How large should timestamps be, in pixels. Defaults to Font Size if not set.', component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val <= 0 ) - return null; - - return val; - } + process: 'to_int', + bounds: [1] } }); @@ -160,13 +155,8 @@ export default class Chat extends Module { title: 'Font Size', description: "How large should text in chat be, in pixels. This may be affected by your browser's zoom and font size settings.", component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val <= 0 ) - return 13; - - return val; - } + process: 'to_int', + bounds: [1] } }); @@ -252,13 +242,8 @@ export default class Chat extends Module { title: 'Scrollback Length', description: 'Keep up to this many lines in chat. Setting this too high will create lag.', component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val < 1 ) - val = 150; - - return val; - } + process: 'to_int', + bounds: [1] } }); @@ -944,10 +929,7 @@ export default class Chat extends Module { description: 'Set the minimum contrast ratio used by Luma adjustments when determining readability.', component: 'setting-text-box', - - process(val) { - return parseFloat(val) - } + process: 'to_float' } }); diff --git a/src/modules/main_menu/components/setting-text-box.vue b/src/modules/main_menu/components/setting-text-box.vue index b6c3d92b..76f750b6 100644 --- a/src/modules/main_menu/components/setting-text-box.vue +++ b/src/modules/main_menu/components/setting-text-box.vue @@ -15,6 +15,7 @@ :type="type" :placeholder="placeholder" :value="value" + :class="{'ffz-input--error': ! isValid}" class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-mg-05 ffz-input" @change="onChange" > diff --git a/src/modules/main_menu/index.js b/src/modules/main_menu/index.js index a3c3749b..51bbe64d 100644 --- a/src/modules/main_menu/index.js +++ b/src/modules/main_menu/index.js @@ -848,6 +848,9 @@ export default class MainMenu extends Module { deleteProfile: profile => settings.deleteProfile(profile), + getProcessor: key => settings.getProcessor(key), + getValidator: key => settings.getValidator(key), + getFFZ: () => t.resolve('core'), provider: { @@ -927,7 +930,7 @@ export default class MainMenu extends Module { }, _context_changed() { - this.context = deep_copy(context._context); + this.context = deep_copy(context.__context); const profiles = context.manager.__profiles, ids = this.profiles = context.__profiles.map(profile => profile.id); diff --git a/src/modules/main_menu/provider-mixin.js b/src/modules/main_menu/provider-mixin.js index 28709616..b5da3b24 100644 --- a/src/modules/main_menu/provider-mixin.js +++ b/src/modules/main_menu/provider-mixin.js @@ -59,6 +59,10 @@ export default { return deep_copy(this.item.default); }, + isValid() { + return this.isDefault || this.validate(this.value) + }, + isDefault() { return ! this.has_value } @@ -78,13 +82,38 @@ export default { } }, - set(value) { - if ( this.item.process ) - value = this.item.process(value); + validate(value) { + let validate = this.item.validator; + if ( ! validate && typeof this.item.process === 'string' ) + validate = this.context.getValidator(`process_${this.item.process}`); + if ( validate ) { + if ( typeof validate !== 'function' ) + validate = this.context.getValidator(validate); + if ( typeof validate === 'function' ) + return validate(value, this.item, this); + else + throw new Error(`Invalid Validator for ${this.item.setting}`); + } + return true; + }, + + set(value) { const provider = this.context.provider, setting = this.item.setting; + // TODO: Run validation. + + let process = this.item.process; + if ( process ) { + if ( typeof process !== 'function' ) + process = this.context.getProcessor(process); + if ( typeof process === 'function' ) + value = process(value, this.default_value, this.item, this); + else + throw new Error(`Invalid processor for ${setting}`); + } + provider.set(setting, value); this.has_value = true; this.value = deep_copy(value); diff --git a/src/modules/main_menu/setting-mixin.js b/src/modules/main_menu/setting-mixin.js index 9977a9e8..44570769 100644 --- a/src/modules/main_menu/setting-mixin.js +++ b/src/modules/main_menu/setting-mixin.js @@ -93,10 +93,7 @@ export default { }, isValid() { - if ( typeof this.item.validator === 'function' ) - return this.item.validator(this.value, this); - - return true; + return this.isDefault || this.validate(this.value); }, sourceOrder() { @@ -195,9 +192,34 @@ export default { }, 0); }, + validate(value) { + let validate = this.item.validator; + if ( ! validate && typeof this.item.process === 'string' ) + validate = this.context.getValidator(`process_${this.item.process}`); + if ( validate ) { + if ( typeof validate !== 'function' ) + validate = this.context.getValidator(validate); + if ( typeof validate === 'function' ) + return validate(value, this.item, this); + else + throw new Error(`Invalid Validator for ${this.item.setting}`); + } + + return true; + }, + set(value) { - if ( this.item.process ) - value = this.item.process(value); + // TODO: Run validation. + + let process = this.item.process; + if ( process ) { + if ( typeof process !== 'function' ) + process = this.context.getProcessor(process); + if ( typeof process === 'function' ) + value = process(value, this.default_value, this.item, this); + else + throw new Error(`Invalid processor for ${this.item.setting}`); + } this.profile.set(this.item.setting, value); diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx index 45ff9858..2157808a 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.jsx @@ -60,13 +60,8 @@ export default class Metadata extends Module { description: 'When the current stream delay exceeds this number of seconds, display the stream delay in a warning color to draw attention to the large delay. Set to zero to disable.', component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val < 0 ) - return 0; - - return val; - }, + process: 'to_float', + bounds: [0, true] } }); diff --git a/src/settings/filters.js b/src/settings/filters.js index 179eee43..d7688c7b 100644 --- a/src/settings/filters.js +++ b/src/settings/filters.js @@ -248,7 +248,7 @@ export const Page = { if ( typeof part === 'object' ) { const val = config.values[part.name]; if ( val && val.length ) - parts.push([i, val]); + parts.push([i, val.toLowerCase()]); i++; } @@ -262,9 +262,14 @@ export const Page = { if ( ! ctx.route || ! ctx.route_data || ctx.route.name !== name ) return false; - for(const [index, value] of parts) - if ( ctx.route_data[index] !== value ) + for(const [index, value] of parts) { + let thing = ctx.route_data[index]; + if ( typeof thing === 'string' ) + thing = thing.toLowerCase(); + + if ( thing !== value ) return false; + } return true; } diff --git a/src/settings/index.js b/src/settings/index.js index e0e8145c..15a9d4a4 100644 --- a/src/settings/index.js +++ b/src/settings/index.js @@ -12,6 +12,8 @@ import SettingsProfile from './profile'; import SettingsContext from './context'; import MigrationManager from './migration'; +import * as PROCESSORS from './processors'; +import * as VALIDATORS from './validators'; import * as PROVIDERS from './providers'; import * as FILTERS from './filters'; import * as CLEARABLES from './clearables'; @@ -77,6 +79,20 @@ export default class SettingsManager extends Module { this.ui_structures = new Map; this.definitions = new Map; + // Validators + this.validators = {}; + + for(const key in VALIDATORS) + if ( has(VALIDATORS, key) ) + this.validators[key] = VALIDATORS[key]; + + // Processors + this.processors = {}; + + for(const key in PROCESSORS) + if ( has(PROCESSORS, key) ) + this.processors[key] = PROCESSORS[key]; + // Clearable Data Rules this.clearables = {}; @@ -927,6 +943,46 @@ export default class SettingsManager extends Module { getClearables() { return deep_copy(this.clearables); } + + + addProcessor(key, fn) { + if ( typeof key === 'object' ) { + for(const k in key) + if ( has(key, k) ) + this.addProcessor(k, key[k]); + return; + } + + this.processors[key] = fn; + } + + getProcessor(key) { + return this.processors[key]; + } + + getProcessors() { + return deep_copy(this.processors); + } + + + addValidator(key, fn) { + if ( typeof key === 'object' ) { + for(const k in key) + if ( has(key, k) ) + this.addValidator(k, key[k]); + return; + } + + this.validators[key] = fn; + } + + getValidator(key) { + return this.validators[key]; + } + + getValidators() { + return deep_copy(this.validators); + } } diff --git a/src/settings/processors.js b/src/settings/processors.js new file mode 100644 index 00000000..66c87025 --- /dev/null +++ b/src/settings/processors.js @@ -0,0 +1,49 @@ +'use strict'; + +const BAD = Symbol('BAD'); + +const do_number = (val, default_value, def) => { + if ( typeof val !== 'number' || isNaN(val) || ! isFinite(val) ) + val = BAD; + + if ( val !== BAD ) { + const bounds = def.bounds; + if ( Array.isArray(bounds) ) { + if ( bounds.length >= 3 ) { + // [low, inclusive, high, inclusive] + if ( (bounds[1] ? (val < bounds[0]) : (val <= bounds[0])) || + (bounds[3] ? (val > bounds[2]) : (val >= bounds[2])) ) + val = BAD; + + } else if ( bounds.length === 2 ) { + // [low, inclusive] or [low, high] ? + if ( typeof bounds[1] === 'boolean' ) { + if ( bounds[1] ? val < bounds[0] : val <= bounds[0] ) + val = BAD; + } else if ( val < bounds[0] || val > bounds[1] ) + val = BAD; + } else if ( bounds.length === 1 && val < bounds[0] ) + val = BAD; + } + } + + return val === BAD ? default_value : val; +} + +export const to_int = (val, default_value, def) => { + if ( typeof val === 'string' && ! /^-?\d+$/.test(val) ) + val = BAD; + else + val = parseInt(val, 10); + + return do_number(val, default_value, def); +} + +export const to_float = (val, default_value, def) => { + if ( typeof val === 'string' && ! /^-?[\d.]+$/.test(val) ) + val = BAD; + else + val = parseFloat(val); + + return do_number(val, default_value, def); +} \ No newline at end of file diff --git a/src/settings/profile.js b/src/settings/profile.js index ab4561d6..2899523b 100644 --- a/src/settings/profile.js +++ b/src/settings/profile.js @@ -5,33 +5,11 @@ // ============================================================================ import {EventEmitter} from 'utilities/events'; -import {has} from 'utilities/object'; +import {isValidShortcut, has} from 'utilities/object'; import {createTester} from 'utilities/filtering'; const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null); -// TODO: Move this into its own file. -const BAD_SHORTCUTS = [ - 'f', - 'space', - 'k', - 'shift+up', - 'shift+down', - 'esc', - 'm', - '?', - 'alt+t', - 'alt+x' -]; - -function isValidShortcut(key) { - if ( ! key ) - return false; - - key = key.toLowerCase().trim(); - return ! BAD_SHORTCUTS.includes(key); -} - /** * Instances of SettingsProfile are used for getting and setting raw settings * values, enumeration, and emit events when the raw settings are changed. diff --git a/src/settings/validators.js b/src/settings/validators.js new file mode 100644 index 00000000..0c8d8255 --- /dev/null +++ b/src/settings/validators.js @@ -0,0 +1,45 @@ +'use strict'; + +const do_number = (val, def) => { + if ( typeof val !== 'number' || isNaN(val) || ! isFinite(val) ) + return false; + + const bounds = def.bounds; + if ( Array.isArray(bounds) ) { + if ( bounds.length >= 3 ) { + // [low, inclusive, high, inclusive] + if ( (bounds[1] ? (val < bounds[0]) : (val <= bounds[0])) || + (bounds[3] ? (val > bounds[2]) : (val >= bounds[2])) ) + return false; + + } else if ( bounds.length === 2 ) { + // [low, inclusive] or [low, high] ? + if ( typeof bounds[1] === 'boolean' ) { + if ( bounds[1] ? val < bounds[0] : val <= bounds[0] ) + return false; + } else if ( val < bounds[0] || val > bounds[1] ) + return false; + } else if ( bounds.length === 1 && val < bounds[0] ) + return false; + } + + return true; +} + +export const process_to_int = (val, def) => { + if ( typeof val === 'string' && ! /^-?\d+$/.test(val) ) + return false; + else + val = parseInt(val, 10); + + return do_number(val, def); +} + +export const process_to_float = (val, def) => { + if ( typeof val === 'string' && ! /^-?[\d.]+$/.test(val) ) + return false; + else + val = parseFloat(val); + + return do_number(val, def); +} \ No newline at end of file diff --git a/src/sites/clips/index.jsx b/src/sites/clips/index.jsx index d03f1ea4..64eaf6de 100644 --- a/src/sites/clips/index.jsx +++ b/src/sites/clips/index.jsx @@ -45,6 +45,7 @@ export default class ClipsSite extends BaseSite { this.css_tweaks.rules = { 'unfollow-button': '.follow-btn__follow-btn--following,.follow-btn--following', + 'player-gain-volume': '.video-player__overlay[data-compressed="true"] .volume-slider__slider-container:not(.ffz--player-gain)', 'player-ext': '.video-player .extension-taskbar,.video-player .extension-container,.video-player .extensions-dock__layout,.video-player .extensions-notifications,.video-player .extensions-video-overlay-size-container,.video-player .extensions-dock__layout', 'player-ext-hover': '.video-player__overlay[data-controls="false"] .extension-taskbar,.video-player__overlay[data-controls="false"] .extension-container,.video-player__overlay[data-controls="false"] .extensions-dock__layout,.video-player__overlay[data-controls="false"] .extensions-notifications,.video-player__overlay[data-controls="false"] .extensions-video-overlay-size-container', 'dark-toggle': 'div[data-a-target="dark-mode-toggle"],div[data-a-target="dark-mode-toggle"] + .tw-border-b' diff --git a/src/sites/player/index.jsx b/src/sites/player/index.jsx index f04dd7dd..6a10d63f 100644 --- a/src/sites/player/index.jsx +++ b/src/sites/player/index.jsx @@ -35,6 +35,7 @@ export default class PlayerSite extends BaseSite { this.css_tweaks.rules = { 'unfollow-button': '.follow-btn--following', + 'player-gain-volume': '.video-player__overlay[data-compressed="true"] .volume-slider__slider-container:not(.ffz--player-gain)', 'player-ext': '.video-player .extension-taskbar,.video-player .extension-container,.video-player .extensions-dock__layout,.video-player .extensions-notifications,.video-player .extensions-video-overlay-size-container,.video-player .extensions-dock__layout', 'player-ext-hover': '.video-player__overlay[data-controls="false"] .extension-taskbar,.video-player__overlay[data-controls="false"] .extension-container,.video-player__overlay[data-controls="false"] .extensions-dock__layout,.video-player__overlay[data-controls="false"] .extensions-notifications,.video-player__overlay[data-controls="false"] .extensions-video-overlay-size-container' }; diff --git a/src/sites/shared/player.jsx b/src/sites/shared/player.jsx index 4a3f4b64..0a7f824a 100644 --- a/src/sites/shared/player.jsx +++ b/src/sites/shared/player.jsx @@ -7,12 +7,46 @@ import Module from 'utilities/module'; import {createElement, on, off} from 'utilities/dom'; -import {debounce} from 'utilities/object'; +import {isValidShortcut, debounce} from 'utilities/object'; import { IS_FIREFOX } from 'src/utilities/constants'; const STYLE_VALIDATOR = createElement('span'); -const HAS_COMPRESSOR = window.AudioContext && window.DynamicsCompressorNode != null; +const HAS_COMPRESSOR = window.AudioContext && window.DynamicsCompressorNode != null, + HAS_GAIN = HAS_COMPRESSOR && window.GainNode != null; + +const SCROLL_I18N = 'setting.entry.player.volume-scroll.values', + SCROLL_OPTIONS = [ + {value: false, title: 'Disabled', i18n_key: `${SCROLL_I18N}.false`}, + {value: true, title: 'Enabled', i18n_key: `${SCROLL_I18N}.true`}, + {value: 2, title: 'Enabled with Right-Click', i18n_key: `${SCROLL_I18N}.2`}, + {value: 3, title: 'Enabled with Alt', i18n_key: `${SCROLL_I18N}.3`}, + {value: 4, title: 'Enabled with Alt + Right-Click', i18n_key: `${SCROLL_I18N}.4`}, + {value: 5, title: 'Enabled with Shift', i18n_key: `${SCROLL_I18N}.5`}, + {value: 6, title: 'Enabled with Shift + Right-Click', i18n_key: `${SCROLL_I18N}.6`}, + {value: 7, title: 'Enabled with Ctrl', i18n_key: `${SCROLL_I18N}.7`}, + {value: 8, title: 'Enabled with Ctrl + Right-Click', i18n_key: `${SCROLL_I18N}.8`} + ]; + +function wantsRMB(setting) { + return setting === 2 || setting === 4 || setting === 6 || setting === 8; +} + +function matchesEvent(setting, event, has_rmb) { + if ( wantsRMB(setting) && event.button !== 2 && ! has_rmb ) + return false; + + if ( ! event.altKey && (setting === 3 || setting === 4) ) + return false; + + if ( ! event.shiftKey && (setting === 5 || setting === 6) ) + return false; + + if ( ! event.ctrlKey && (setting === 7 || setting === 8) ) + return false; + + return true; +} function rotateButton(event) { const target = event.currentTarget, @@ -38,6 +72,8 @@ export default class PlayerBase extends Module { this.inject('site.fine'); this.inject('site.css_tweaks'); + this.onShortcut = this.onShortcut.bind(this); + this.registerSettings(); } @@ -81,6 +117,136 @@ export default class PlayerBase extends Module { } }); + this.settings.add('player.compressor.shortcut', { + default: null, + requires: ['player.compressor.enable'], + process(ctx, val) { + if ( ! ctx.get('player.compressor.enable') ) + return null; + return val; + }, + ui: { + path: 'Player > Compressor >> General', + title: 'Shortcut Key', + description: 'This key sequence can be used to toggle the compressor.', + component: 'setting-hotkey' + }, + changed: () => { + this.updateShortcut(); + for(const inst of this.Player.instances) + this.addCompressorButton(inst); + } + }); + + if ( HAS_GAIN ) { + this.settings.add('player.gain.enable', { + default: false, + ui: { + sort: -1, + path: 'Player > Compressor >> Gain Control @{"sort": 50, "description": "Gain Control gives you extra control over the output volume when using the Compressor by letting you adjust the volume after the compressor runs, while the built-in volume slider takes affect before the compressor. This uses a simple [GainNode](https://developer.mozilla.org/en-US/docs/Web/API/GainNode) from the Web Audio API, connected in sequence after the DynamicsCompressorNode the Compressor uses."}', + title: 'Enable gain control when the audio compressor is enabled.', + component: 'setting-check-box' + }, + + changed: () => { + for(const inst of this.Player.instances) + this.compressPlayer(inst); + } + }); + + this.settings.add('player.gain.no-volume', { + default: false, + requires: ['player.gain.enable'], + process(ctx, val) { + if ( ! ctx.get('player.gain.enable') ) + return false; + return val; + }, + + ui: { + path: 'Player > Compressor >> Gain Control', + title: 'Force built-in volume to 100% when the audio compressor is enabled.', + description: 'With this enabled, the built-in volume will be hidden and the Gain Control will be the only way to change volume.', + component: 'setting-check-box' + }, + + changed: val => { + this.css_tweaks.toggleHide('player-gain-volume', val); + for(const inst of this.Player.instances) + this.updateGainVolume(inst); + } + }); + + this.settings.add('player.gain.scroll', { + default: false, + ui: { + path: 'Player > Compressor >> Gain Control', + title: 'Scroll Adjust', + description: 'Adjust the gain by scrolling with the mouse wheel. This setting takes precedence over adjusting the volume by scrolling. *This setting will not work properly on streams with visible extensions when mouse interaction with extensions is allowed.*', + component: 'setting-select-box', + data: SCROLL_OPTIONS + } + }); + + this.settings.add('player.gain.default', { + default: 100, + requires: ['player.gain.min', 'player.gain.max'], + process(ctx, val) { + const min = ctx.get('player.gain.min'), + max = ctx.get('player.gain.max'); + + val /= 100; + + if ( val < min ) + val = min; + if ( val > max ) + val = max; + + return val; + }, + ui: { + path: 'Player > Compressor >> Gain Control', + title: 'Default Value', + component: 'setting-text-box', + description: 'The default value for gain control, when gain control is enabled. 100% means no change in volume.', + process: 'to_int', + bounds: [0, true] + }, + + changed: () => this.updateGains() + }); + + this.settings.add('player.gain.min', { + default: 0, + process: (ctx, val) => val / 100, + ui: { + path: 'Player > Compressor >> Gain Control', + title: 'Minimum', + component: 'setting-text-box', + description: '**Range:** 0 ~ 100\n\nThe minimum allowed value for gain control. 0% is effectively muted.', + process: 'to_int', + bounds: [0, true, 100, true] + }, + + changed: () => this.updateGains() + }); + + this.settings.add('player.gain.max', { + default: 200, + process: (ctx, val) => val / 100, + ui: { + path: 'Player > Compressor >> Gain Control', + title: 'Maximum', + component: 'setting-text-box', + description: '**Range:** 100 ~ 1000\n\nThe maximum allowed value for gain control. 100% is no change. 200% is double the volume.', + process: 'to_int', + bounds: [100, true, 1000, true] + }, + + changed: () => this.updateGains() + }); + } + this.settings.add('player.compressor.threshold', { default: -50, ui: { @@ -89,13 +255,8 @@ export default class PlayerBase extends Module { sort: 0, description: '**Range:** -100 ~ 0\n\nThe decibel value above which the compression will start taking effect.', component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val > 0 || val < -100 ) - return -50; - - return val; - } + process: 'to_int', + bounds: [-100, true, 0, true] }, changed: () => this.updateCompressors() @@ -109,13 +270,8 @@ export default class PlayerBase extends Module { sort: 5, description: '**Range:** 0 ~ 40\n\nA decibel value representing the range above the threshold where the curve smoothly transitions to the compressed portion.', component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val < 0 || val > 40 ) - return 40; - - return val; - } + process: 'to_int', + bounds: [0, true, 40, true] }, changed: () => this.updateCompressors() @@ -129,13 +285,8 @@ export default class PlayerBase extends Module { sort: 10, description: '**Range:** 0 ~ 20\n\nThe amount of change, in dB, needed in the input for a 1 dB change in the output.', component: 'setting-text-box', - process(val) { - val = parseInt(val, 10); - if ( isNaN(val) || ! isFinite(val) || val < 1 || val > 20 ) - return 12; - - return val; - } + process: 'to_int', + bounds: [0, true, 20, true] }, changed: () => this.updateCompressors() @@ -149,13 +300,8 @@ export default class PlayerBase extends Module { sort: 15, description: '**Range:** 0 ~ 1\n\nThe amount of time, in seconds, required to reduce the gain by 10 dB.', component: 'setting-text-box', - process(val) { - val = parseFloat(val); - if ( isNaN(val) || ! isFinite(val) || val < 0 || val > 1 ) - return 0; - - return val; - } + process: 'to_float', + bounds: [0, true, 1, true] }, changed: () => this.updateCompressors() @@ -169,13 +315,8 @@ export default class PlayerBase extends Module { sort: 20, description: '**Range:** 0 ~ 1\nThe amount of time, in seconds, required to increase the gain by 10 dB.', component: 'setting-text-box', - process(val) { - val = parseFloat(val); - if ( isNaN(val) || ! isFinite(val) || val < 0 || val > 1 ) - return 0.25; - - return val; - } + process: 'to_float', + bounds: [0, true, 1, true] }, changed: () => this.updateCompressors() @@ -210,11 +351,7 @@ export default class PlayerBase extends Module { title: 'Adjust volume by scrolling with the mouse wheel.', description: '*This setting will not work properly on streams with visible extensions when mouse interaction with extensions is allowed.*', component: 'setting-select-box', - data: [ - {value: false, title: 'Disabled'}, - {value: true, title: 'Enabled'}, - {value: 2, title: 'Enabled with Right-Click'} - ] + data: SCROLL_OPTIONS } }); @@ -408,6 +545,7 @@ export default class PlayerBase extends Module { await this.settings.awaitProvider(); await this.settings.provider.awaitReady(); + this.css_tweaks.toggleHide('player-gain-volume', this.settings.get('player.gain.no-volume')); this.css_tweaks.toggle('player-volume', this.settings.get('player.volume-always-shown')); this.css_tweaks.toggle('player-ext-mouse', !this.settings.get('player.ext-interaction')); this.css_tweaks.toggle('player-hide-mouse', this.settings.get('player.hide-mouse')); @@ -415,6 +553,7 @@ export default class PlayerBase extends Module { this.installVisibilityHook(); this.updateHideExtensions(); this.updateCaptionsCSS(); + this.updateShortcut(); this.on(':reset', this.resetAllPlayers, this); @@ -465,6 +604,32 @@ export default class PlayerBase extends Module { }); } + updateShortcut() { + const Mousetrap = this.Mousetrap = this.Mousetrap || this.resolve('site.web_munch')?.getModule?.('mousetrap') || window.Mousetrap; + if ( ! Mousetrap || ! Mousetrap.bind ) + return; + + if ( this._shortcut_bound ) { + Mousetrap.unbind(this._shortcut_bound); + this._shortcut_bound = null; + } + + const key = this.settings.get('player.compressor.shortcut'); + if ( HAS_COMPRESSOR && key && isValidShortcut(key) ) { + Mousetrap.bind(key, this.onShortcut); + this._shortcut_bound = key; + } + } + + + onShortcut(e) { + this.log.info('Compressor Hotkey', e); + + for(const inst of this.Player.instances) + this.compressPlayer(inst, e); + } + + modifyPlayerClass(cls) { const t = this, old_attach = cls.prototype.maybeAttachDomEventListeners; @@ -611,15 +776,19 @@ export default class PlayerBase extends Module { if ( player.core ) player = player.core; - const state = player.state?.state; + const state = player.state?.state, + video = player.mediaSinkManager?.video; + if ( state === 'Playing' ) { - const video = player.mediaSinkManager?.video; if ( video?._ffz_maybe_compress ) { video._ffz_maybe_compress = false; t.compressPlayer(this); } } + if ( video && video._ffz_compressed != null ) + ds.compressed = video._ffz_compressed; + ds.ended = state === 'Ended'; ds.paused = state === 'Idle'; } @@ -671,7 +840,12 @@ export default class PlayerBase extends Module { if ( ! event ) return; - if ( t.settings.get('player.volume-scroll') === 2 && event.button === 2 ) { + const vol_scroll = t.settings.get('player.volume-scroll'), + gain_scroll = t.settings.get('player.gain.scroll'), + + wants_rmb = wantsRMB(vol_scroll) || wantsRMB(gain_scroll); + + if ( wants_rmb && event.button === 2 ) { this.ffz_rmb = true; this.ffz_scrolled = false; } @@ -699,33 +873,59 @@ export default class PlayerBase extends Module { } cls.prototype.ffzScrollHandler = function(event) { - const setting = t.settings.get('player.volume-scroll'); - if ( ! setting ) - return; + const vol_scroll = t.settings.get('player.volume-scroll'), + gain_scroll = t.settings.get('player.gain.scroll'), - if ( setting === 2 && ! this.ffz_rmb ) + matches_gain = gain_scroll && matchesEvent(gain_scroll, event, this.ffz_rmb), + matches_vol = ! matches_gain && vol_scroll && matchesEvent(vol_scroll, event, this.ffz_rmb); + + if ( ! matches_gain && ! matches_vol ) return; const delta = event.wheelDelta || -(event.deltaY || event.detail || 0), player = this.props?.mediaPlayerInstance, video = player?.mediaSinkManager?.video || player?.core?.mediaSinkManager?.video; - if ( ! player?.getVolume ) + if ( ! player?.getVolume || (matches_gain && ! video) ) return; - if ( setting === 2 ) + if ( matches_gain ? wantsRMB(gain_scroll) : wantsRMB(vol_scroll) ) this.ffz_scrolled = true; - const amount = t.settings.get('player.volume-scroll-steps'), - old_volume = video?.volume ?? player.getVolume(), - volume = Math.max(0, Math.min(1, old_volume + (delta > 0 ? amount : -amount))); + const amount = t.settings.get('player.volume-scroll-steps'); - player.setVolume(volume); - localStorage.volume = volume; + if ( matches_vol && ! (video._ffz_compressed && t.settings.get('player.gain.no-volume')) ) { + const old_volume = video?.volume ?? player.getVolume(), + volume = Math.max(0, Math.min(1, old_volume + (delta > 0 ? amount : -amount))); - if ( volume !== 0 ) { - player.setMuted(false); - localStorage.setItem('video-muted', JSON.stringify({default: false})); + player.setVolume(volume); + localStorage.volume = volume; + + if ( volume !== 0 ) { + player.setMuted(false); + localStorage.setItem('video-muted', JSON.stringify({default: false})); + } + + } else if ( matches_gain ) { + let value = video._ffz_gain_value; + if ( value == null ) + value = t.settings.get('player.gain.default'); + + const min = t.settings.get('player.gain.min'), + max = t.settings.get('player.gain.max'); + + if ( delta > 0 ) + value += amount; + else + value -= amount; + + if ( value < min ) + value = min; + if ( value > max ) + value = max; + + video._ffz_gain_value = value; + t.updateGain(this); } event.preventDefault(); @@ -902,6 +1102,7 @@ export default class PlayerBase extends Module { this.addPiPButton(inst); this.addResetButton(inst); this.addCompressorButton(inst, false); + this.addGainSlider(inst, false); this.addMetadata(inst); if ( inst._ffzUpdateVolume ) @@ -910,6 +1111,122 @@ export default class PlayerBase extends Module { this.emit(':update-gui', inst); } + addGainSlider(inst, visible_only, tries = 0) { + const outer = inst.props.containerRef || this.fine.getChildNode(inst), + video = inst.props.mediaPlayerInstance?.mediaSinkManager?.video || inst.props.mediaPlayerInstance?.core?.mediaSinkManager?.video, + container = outer && outer.querySelector('.player-controls__left-control-group'); + let gain = video != null && video._ffz_compressed && video._ffz_gain; + + if ( ! container ) { + if ( video && ! gain ) + return; + + if ( tries < 5 ) + return setTimeout(this.addGainSlider.bind(this, inst, visible_only, (tries || 0) + 1), 250); + + return; + } + + const min = this.settings.get('player.gain.min'), + max = this.settings.get('player.gain.max'); + + if ( min >= max || max <= min ) + gain = null; + + let tip, input, extra, fill, cont = container.querySelector('.ffz--player-gain'); + if ( ! gain ) { + if ( cont ) + cont.remove(); + return; + } + + if ( ! cont ) { + const on_change = () => { + let value = input.value / 100; + + const min = this.settings.get('player.gain.min'), + max = this.settings.get('player.gain.max'); + + if ( value < min ) + value = min; + if ( value > max ) + value = max; + + video._ffz_gain_value = value; + gain.gain.value = value; + + const range = max - min, + width = (value - min) / range; + + fill.style.width = `${width * 100}%`; + extra.textContent = `${Math.round(value * 100)}%`; + }; + + cont = (
+
+ +
+ {input = ()} +
+
+ {fill = (
)} +
+
+
+
+