ultrawidify/src/ext/lib/video-transform/Resizer.ts

1003 lines
37 KiB
TypeScript
Raw Normal View History

import { SiteSettings } from './../settings/SiteSettings';
import Debug from '../../conf/Debug';
import Scaler, { CropStrategy, VideoDimensions } from './Scaler';
import Stretcher from './Stretcher';
import Zoom from './Zoom';
import StretchType from '../../../common/enums/StretchType.enum';
import VideoAlignmentType from '../../../common/enums/VideoAlignmentType.enum';
import AspectRatioType from '../../../common/enums/AspectRatioType.enum';
2021-02-08 22:45:51 +01:00
import VideoData from '../video-data/VideoData';
2021-10-26 22:19:41 +02:00
import EventBus from '../EventBus';
import { _cp } from '../../../common/js/utils';
import Settings from '../Settings';
2025-04-26 04:23:57 +02:00
import { Ar, ArVariant } from '../../../common/interfaces/ArInterface';
2024-05-07 19:05:10 +02:00
import { RunLevel } from '../../enum/run-level.enum';
2024-06-12 20:29:00 +02:00
import * as _ from 'lodash';
import getElementStyles from '../../util/getElementStyles';
2025-01-14 01:44:25 +01:00
import { Stretch } from '../../../common/interfaces/StretchInterface';
2025-05-04 02:18:58 +02:00
import { ComponentLogger } from '../logging/ComponentLogger';
if(Debug.debug) {
console.log("Loading: Resizer.js");
}
2025-04-26 04:23:57 +02:00
enum ResizerMode {
Crop = 1,
Zoom = 2
};
2023-07-10 18:27:06 +02:00
/**
* Resizer is the top class and is responsible for figuring out which component needs to crop, which
* component needs to zoom, and which component needs to stretch.
*
* It also kinda does lots of the work that should prolly be moved to Scaler.
*
*/
class Resizer {
2021-02-08 22:45:51 +01:00
//#region flags
canPan: boolean = false;
destroyed: boolean = false;
2021-10-31 23:19:32 +01:00
manualZoom: boolean = false;
2021-02-08 22:45:51 +01:00
//#endregion
//#region helper objects
2025-05-04 02:18:58 +02:00
logger: ComponentLogger;
2021-02-08 22:45:51 +01:00
settings: Settings;
siteSettings: SiteSettings;
2021-02-08 22:45:51 +01:00
scaler: Scaler;
stretcher: Stretcher;
zoom: Zoom;
videoData: VideoData;
2021-10-26 22:19:41 +02:00
eventBus: EventBus;
2021-02-08 22:45:51 +01:00
//#endregion
//#region HTML elements
video: HTMLVideoElement;
2021-02-08 22:45:51 +01:00
//#endregion
//#region data
correctedVideoDimensions: any;
currentCss: any;
currentStyleString: string;
currentPlayerStyleString: any;
currentCssValidFor: any;
currentVideoSettings: any;
_lastAr: Ar = {type: AspectRatioType.Initial};
set lastAr(x: Ar) {
// emit updates for UI when setting lastAr, but only if AR really changed
if (this._lastAr?.type !== x.type || this._lastAr?.ratio !== x.ratio) {
this.eventBus.send('uw-config-broadcast', {type: 'ar', config: x});
}
this._lastAr = x;
}
get lastAr() {
return this._lastAr;
}
2021-02-08 22:45:51 +01:00
resizerId: any;
2021-10-26 22:19:41 +02:00
videoAlignment: {x: VideoAlignmentType, y: VideoAlignmentType};
2021-02-08 22:45:51 +01:00
userCss: string;
userCssClassName: any;
pan: any = null;
//#endregion
2023-03-30 00:43:30 +02:00
cycleableAspectRatios: Ar[];
2025-04-26 04:23:57 +02:00
cycleableZoomAspectRatios: Ar[];
2023-03-30 00:43:30 +02:00
nextCycleOptionIndex = 0;
2021-10-26 22:19:41 +02:00
//#region event bus configuration
private eventBusCommands = {
'set-ar': [{
2021-10-31 23:19:32 +01:00
function: (config: any) => {
this.manualZoom = false; // this only gets called from UI or keyboard shortcuts, making this action safe.
2023-03-30 00:43:30 +02:00
if (config.type !== AspectRatioType.Cycle) {
2025-04-26 04:23:57 +02:00
this.setAr({...config, variant: ArVariant.Crop});
2023-03-30 00:43:30 +02:00
} else {
// if we manually switched to a different aspect ratio, cycle from that ratio forward
const lastArIndex = this.cycleableAspectRatios.findIndex(x => x.type === this.lastAr.type && x.ratio === this.lastAr.ratio);
if (lastArIndex !== -1) {
this.nextCycleOptionIndex = (lastArIndex + 1) % this.cycleableAspectRatios.length;
}
2025-04-26 04:23:57 +02:00
this.setAr({...this.cycleableAspectRatios[this.nextCycleOptionIndex], variant: ArVariant.Crop});
2023-03-30 00:43:30 +02:00
this.nextCycleOptionIndex = (this.nextCycleOptionIndex + 1) % this.cycleableAspectRatios.length;
}
2021-10-31 23:19:32 +01:00
}
2021-10-26 22:19:41 +02:00
}],
2025-04-26 04:23:57 +02:00
'set-ar-zoom': [{
function: (config: any) => {
this.manualZoom = false; // this only gets called from UI or keyboard shortcuts, making this action safe.
if (config.type !== AspectRatioType.Cycle) {
this.setAr({...config, variant: ArVariant.Zoom});
} else {
// if we manually switched to a different aspect ratio, cycle from that ratio forward
const lastArIndex = this.cycleableZoomAspectRatios.findIndex(x => x.type === this.lastAr.type && x.ratio === this.lastAr.ratio);
if (lastArIndex !== -1) {
this.nextCycleOptionIndex = (lastArIndex + 1) % this.cycleableZoomAspectRatios.length;
}
this.setAr({...this.cycleableZoomAspectRatios[this.nextCycleOptionIndex], variant: ArVariant.Zoom});
this.nextCycleOptionIndex = (this.nextCycleOptionIndex + 1) % this.cycleableZoomAspectRatios.length;
}
}
}],
2021-10-26 22:19:41 +02:00
'set-alignment': [{
function: (config: any) => {
2021-11-03 00:08:34 +01:00
this.setVideoAlignment(config.x, config.y);
2021-10-26 22:19:41 +02:00
}
}],
'set-stretch': [{
2021-10-31 23:19:32 +01:00
function: (config: any) => {
this.manualZoom = false; // we also need to unset manual aspect ratio when doing this
2025-01-14 01:44:25 +01:00
this.setStretchMode(config)
2021-10-31 23:19:32 +01:00
}
2021-10-26 22:19:41 +02:00
}],
'set-zoom': [{
2025-04-26 23:24:57 +02:00
function: (config: any) => this.setZoom(config.zoom)
2021-10-26 22:19:41 +02:00
}],
'change-zoom': [{
2025-04-20 16:11:17 +02:00
function: (config: any) => this.zoomStep(config.zoom)
2021-10-26 22:19:41 +02:00
}],
'get-ar': [{
function: () => this.eventBus.send('uw-config-broadcast', {type: 'ar', config: this.lastAr})
2024-05-07 19:05:10 +02:00
}],
2024-06-10 23:40:49 +02:00
'get-resizer-config': [{
function: () => this.eventBus.send(
'uw-resizer-config-broadcast',
{
ar: this.lastAr,
2025-01-14 01:44:25 +01:00
stretchMode: this.stretcher.stretch,
2024-06-10 23:40:49 +02:00
videoAlignment: this.videoAlignment
}
)
}],
2024-05-07 19:05:10 +02:00
'restore-ar': [{
function: () => this.restore()
2024-06-12 20:29:00 +02:00
}],
'delayed-restore-ar': [{
function: () => {
_.debounce(
this.restore,
500,
{
leading: true,
trailing: true
}
)
}
}]
2021-10-26 22:19:41 +02:00
}
//#endregion
constructor(videoData) {
2021-02-08 22:45:51 +01:00
this.resizerId = (Math.random()*100).toFixed(0);
this.videoData = videoData;
2025-05-04 02:18:58 +02:00
this.logger = new ComponentLogger(videoData.logAggregator, 'Resizer');
this.video = videoData.video;
this.settings = videoData.settings;
this.siteSettings = videoData.siteSettings;
this.eventBus = videoData.eventBus;
this.initEventBus();
this.scaler = new Scaler(this.videoData);
this.stretcher = new Stretcher(this.videoData);
this.zoom = new Zoom(this.videoData);
2018-05-12 02:51:58 +02:00
const defaultCrop = this.siteSettings.getDefaultOption('crop') as {type: AspectRatioType, ratio?: number };
if (defaultCrop.type !== AspectRatioType.Reset) {
this.lastAr = defaultCrop;
}
this.videoAlignment = this.siteSettings.getDefaultOption('alignment') as {x: VideoAlignmentType, y: VideoAlignmentType} // this is initial video alignment
this.destroyed = false;
2018-09-13 23:47:20 +02:00
// if (this.siteSettings.active.pan) {
// this.canPan = this.siteSettings.active.miscSettings.mousePan.enabled;
// } else {
// this.canPan = false;
// }
2023-03-30 00:43:30 +02:00
this.cycleableAspectRatios =
(this.settings?.active?.commands?.crop ?? [])
.filter(x => [AspectRatioType.FitHeight, AspectRatioType.FitWidth, AspectRatioType.Fixed, AspectRatioType.Reset].includes(x?.arguments?.type))
.map(x => x.arguments) as Ar[];
2025-04-26 04:23:57 +02:00
this.cycleableZoomAspectRatios =
(this.settings?.active?.commands?.zoom ?? [])
.filter(x => x.action === 'set-ar-zoom' && x.arguments?.type !== AspectRatioType.Cycle)
.map(x => x.arguments) as Ar[];
2023-03-30 00:43:30 +02:00
this.nextCycleOptionIndex = 0;
2021-10-25 23:11:34 +02:00
this.userCssClassName = videoData.userCssClassName;
2021-10-26 22:19:41 +02:00
}
initEventBus() {
for (const action in this.eventBusCommands) {
for (const command of this.eventBusCommands[action]) {
this.eventBus.subscribe(action, command);
}
}
}
2021-10-25 23:11:34 +02:00
prepareCss(css) {
return `.${this.userCssClassName} {${css}}`;
}
destroy(){
2025-05-04 02:18:58 +02:00
this.logger.info('destroy', `<rid:${this.resizerId}> received destroy command.`);
this.destroyed = true;
}
getFileAr() {
return this.videoData.video.videoWidth / this.videoData.video.videoHeight;
}
2019-03-10 23:27:50 +01:00
calculateRatioForLegacyOptions(ar){
// also present as modeToAr in Scaler.js
if (ar.type !== AspectRatioType.FitWidth && ar.type !== AspectRatioType.FitHeight && ar.ratio) {
2019-04-25 22:02:10 +02:00
return ar;
2019-03-10 23:27:50 +01:00
}
// handles "legacy" options, such as 'fit to widht', 'fit to height' and AspectRatioType.Reset. No zoom tho
2021-02-18 22:38:32 +01:00
let ratioOut;
2019-03-10 23:27:50 +01:00
if (!this.videoData.video) {
2025-05-04 02:18:58 +02:00
this.logger.info('calculateRatioForLegacyOptions', "No video??", this.videoData.video, "— killing videoData");
this.videoData.destroy();
2019-03-10 23:27:50 +01:00
return null;
}
2021-10-25 23:11:34 +02:00
if (! this.videoData.player.dimensions) {
2019-03-10 23:27:50 +01:00
ratioOut = screen.width / screen.height;
2019-04-25 22:02:10 +02:00
} else {
2025-05-04 02:18:58 +02:00
this.logger.info('calculateRatioForLegacyOptions', `<rid:${this.resizerId}> Player dimensions:`, this.videoData.player.dimensions.width ,'x', this.videoData.player.dimensions.height,'aspect ratio:', this.videoData.player.dimensions.width / this.videoData.player.dimensions.height)
ratioOut = this.videoData.player.dimensions.width / this.videoData.player.dimensions.height;
2019-03-10 23:27:50 +01:00
}
2021-10-25 23:11:34 +02:00
2019-03-10 23:27:50 +01:00
// IMPORTANT NOTE: lastAr needs to be set after _res_setAr() is called, as _res_setAr() assumes we're
2021-10-25 23:11:34 +02:00
// setting a static aspect ratio (even if the function is called from here or ArDetect).
let fileAr = this.getFileAr();
2021-10-25 23:11:34 +02:00
if (ar.type === AspectRatioType.FitWidth){
2019-04-25 22:02:10 +02:00
ar.ratio = ratioOut > fileAr ? ratioOut : fileAr;
2019-03-10 23:27:50 +01:00
}
else if(ar.type === AspectRatioType.FitHeight){
2019-04-25 22:02:10 +02:00
ar.ratio = ratioOut < fileAr ? ratioOut : fileAr;
2019-03-10 23:27:50 +01:00
}
else if(ar.type === AspectRatioType.Reset){
2025-05-04 02:18:58 +02:00
this.logger.info('modeToAr', "Using original aspect ratio -", fileAr);
2019-04-25 22:02:10 +02:00
ar.ratio = fileAr;
} else {
return null;
2019-03-10 23:27:50 +01:00
}
2019-04-25 22:02:10 +02:00
return ar;
2019-03-10 23:27:50 +01:00
}
2025-04-26 23:24:57 +02:00
updateAr(ar: Ar) {
if (!ar) {
return;
}
// Some options require a bit more testing re: whether they make sense
// if they don't, we refuse to update aspect ratio until they do
if (ar.type === AspectRatioType.AutomaticUpdate || ar.type === AspectRatioType.Fixed) {
if (!ar.ratio || isNaN(ar.ratio)) {
return;
}
}
// Only update aspect ratio if there's a difference between the old and the new state
2019-11-04 23:53:28 +01:00
if (!this.lastAr || ar.type !== this.lastAr.type || ar.ratio !== this.lastAr.ratio) {
this.setAr(ar);
}
}
2025-04-26 04:23:57 +02:00
/**
* Starts and stops Aard as necessary. Returns 'true' if we can
* stop setting aspect ratio early.
* @param ar
* @param resizerMode
* @returns
*/
private handleAard(ar: Ar): boolean {
if (ar.type === AspectRatioType.Automatic) {
this.videoData.aard?.startCheck(ar.variant);
return true;
} else if (ar.type !== AspectRatioType.AutomaticUpdate) {
this.videoData.aard?.stop();
} else if (this.stretcher.stretch.type === StretchType.Basic) {
this.videoData?.aard?.stop();
}
}
async setAr(ar: Ar, lastAr?: Ar) {
2025-04-26 04:23:57 +02:00
if (this.destroyed || ar == null) {
return;
}
2021-10-25 23:11:34 +02:00
2025-01-05 00:37:20 +01:00
// If no aspect ratio is applied AND if no stretch mode is active,
// we disable our CSS in order to prevent breaking websites by default,
// without any human interaction
if (
[AspectRatioType.Reset, AspectRatioType.Initial].includes(ar.type) &&
2025-01-14 01:44:25 +01:00
[StretchType.NoStretch, StretchType.Default].includes(this.stretcher.stretch.type)
2025-01-05 00:37:20 +01:00
) {
2024-05-07 19:05:10 +02:00
this.eventBus.send('set-run-level', RunLevel.UIOnly);
} else {
this.eventBus.send('set-run-level', RunLevel.CustomCSSActive);
}
// handle autodetection stuff
2025-04-26 04:23:57 +02:00
if (this.handleAard(ar)) {
return;
}
if (ar.type !== AspectRatioType.AutomaticUpdate) {
2021-10-31 23:19:32 +01:00
this.manualZoom = false;
}
if (!this.video.videoWidth || !this.video.videoHeight) {
2025-05-04 02:18:58 +02:00
this.logger.warn('setAr', `<rid:${this.resizerId}> Video has no width or no height. This is not allowed. Aspect ratio will not be set, and videoData will be uninitialized.`);
this.videoData.videoUnloaded();
}
2021-10-25 23:11:34 +02:00
2025-05-04 02:18:58 +02:00
this.logger.info('setAr', `<rid:${this.resizerId}> trying to set ar. New ar:`, ar);
2024-12-30 03:05:47 +01:00
let stretchFactors: VideoDimensions | any;
2019-11-02 02:45:24 +01:00
// reset zoom, but only on aspect ratio switch. We also know that aspect ratio gets converted to
// AspectRatioType.Fixed when zooming, so let's keep that in mind
if (
(ar.type !== AspectRatioType.Fixed && ar.type !== AspectRatioType.Manual) // anything not these two _always_ changes AR
|| ar.type !== this.lastAr.type // this also means aspect ratio has changed
|| ar.ratio !== this.lastAr.ratio // this also means aspect ratio has changed
2025-04-26 04:23:57 +02:00
|| ar.variant !== this.lastAr.variant
) {
2019-11-02 02:45:24 +01:00
this.zoom.reset();
this.resetPan();
}
// most everything that could go wrong went wrong by this stage, and returns can happen afterwards
// this means here's the optimal place to set or forget aspect ratio. Saving of current crop ratio
// is handled in pageInfo.updateCurrentCrop(), which also makes sure to persist aspect ratio if ar
// is set to persist between videos / through current session / until manual reset.
// if (ar.type === AspectRatioType.Reset ||
// ar.type === AspectRatioType.Initial
// ) {
// // reset/undo default
// this.videoData.pageInfo.updateCurrentCrop(undefined);
// } else {
this.videoData.pageInfo.updateCurrentCrop(ar);
// }
2019-04-25 22:02:10 +02:00
if (lastAr) {
this.lastAr = this.calculateRatioForLegacyOptions(lastAr);
ar = this.calculateRatioForLegacyOptions(ar);
2018-05-12 02:51:58 +02:00
} else {
2019-04-25 22:02:10 +02:00
// NOTE: "fitw" "fith" and "reset" should ignore ar.ratio bit, but
// I'm not sure whether they do. Check that.
ar = this.calculateRatioForLegacyOptions(ar);
if (! ar) {
2025-05-04 02:18:58 +02:00
this.logger.info('setAr', `<rid:${this.resizerId}> Something wrong with ar or the player. Doing nothing.`);
2019-04-25 22:02:10 +02:00
return;
}
2021-08-26 00:37:56 +02:00
this.lastAr = {type: ar.type, ratio: ar.ratio};
2018-05-12 02:51:58 +02:00
}
if (! this.video) {
this.videoData.destroy();
}
// do stretch thingy
2025-01-14 01:44:25 +01:00
if ([StretchType.NoStretch, StretchType.Conditional, StretchType.FixedSource].includes(this.stretcher.stretch.type)) {
2021-02-08 22:45:51 +01:00
stretchFactors = this.scaler.calculateCrop(ar);
2025-04-26 04:23:57 +02:00
if (!stretchFactors || stretchFactors.error){
2025-05-04 02:18:58 +02:00
this.logger.error('setAr', ` <rid:${this.resizerId}> failed to set AR due to problem with calculating crop. Error:`, stretchFactors?.error);
if (stretchFactors?.error === 'no_video'){
this.videoData.destroy();
return;
}
// we could have issued calculate crop too early. Let's tell VideoData that there's something wrong
// and exit this function. When <video> will receive onloadeddata or ontimeupdate (receiving either
// of the two means video is loaded or playing, and that means video has proper dimensions), it will
// try to reset or re-apply aspect ratio when the video is finally ready.
if (stretchFactors?.error === 'illegal_video_dimensions') {
this.videoData.videoDimensionsLoaded = false;
return;
}
}
2025-01-14 01:44:25 +01:00
if (this.stretcher.stretch.type === StretchType.Conditional){
this.stretcher.applyConditionalStretch(stretchFactors, ar.ratio);
2025-01-14 01:44:25 +01:00
} else if (this.stretcher.stretch.type === StretchType.FixedSource) {
this.stretcher.applyStretchFixedSource(stretchFactors);
}
2025-05-04 02:18:58 +02:00
this.logger.info('setAr', "Processed stretch factors for ",
2025-01-14 01:44:25 +01:00
this.stretcher.stretch.type === StretchType.NoStretch ? 'stretch-free crop.' :
this.stretcher.stretch.type === StretchType.Conditional ? 'crop with conditional StretchType.' : 'crop with fixed stretch',
'Stretch factors are:', stretchFactors
);
2025-01-14 01:44:25 +01:00
} else if (this.stretcher.stretch.type === StretchType.Hybrid) {
2021-02-08 22:45:51 +01:00
stretchFactors = this.stretcher.calculateStretch(ar.ratio);
2025-05-04 02:18:58 +02:00
this.logger.info('setAr', 'Processed stretch factors for hybrid stretch/crop. Stretch factors are:', stretchFactors);
2025-01-14 01:44:25 +01:00
} else if (this.stretcher.stretch.type === StretchType.Fixed) {
2025-04-26 04:23:57 +02:00
stretchFactors = this.stretcher.calculateStretchFixed(ar.ratio);
2025-01-14 01:44:25 +01:00
} else if (this.stretcher.stretch.type === StretchType.Basic) {
2021-02-08 22:45:51 +01:00
stretchFactors = this.stretcher.calculateBasicStretch();
2025-05-04 02:18:58 +02:00
this.logger.log('setAr', 'Processed stretch factors for basic StretchType. Stretch factors are:', stretchFactors);
} else {
2021-11-02 22:19:50 +01:00
stretchFactors = this.scaler.calculateCrop(ar);
2025-05-04 02:18:58 +02:00
this.logger.error(
'setAr',
'Okay wtf happened? If you see this, something has gone wrong. Pretending stretchMode is set tu NoStretch. Stretch factors are:', stretchFactors,
2021-11-02 22:19:50 +01:00
"\n------[ i n f o d u m p ]------\nstretcher:", this.stretcher,
'\nargs: ar (corrected for legacy):', ar, 'last ar (optional argument):', lastAr
);
}
2024-12-30 03:05:47 +01:00
this.applyScaling(stretchFactors as VideoDimensions);
2021-10-31 23:19:32 +01:00
}
2024-12-30 03:05:47 +01:00
applyScaling(stretchFactors: VideoDimensions, options?: {noAnnounce?: boolean, ar?: Ar}) {
// this.stretcher.chromeBugMitigation(stretchFactors);
2021-01-30 12:16:37 +01:00
// let the UI know
if(!options?.noAnnounce) {
this.videoData.eventBus.send('announce-zoom', {x: stretchFactors.xFactor, y: stretchFactors.yFactor});
}
2021-02-08 22:45:51 +01:00
2024-12-30 03:05:47 +01:00
let translate = this.computeOffsets(stretchFactors, options?.ar);
this.applyCss(stretchFactors, translate);
2018-07-10 20:36:12 +02:00
}
2018-06-15 00:33:10 +02:00
2019-11-02 02:45:24 +01:00
toFixedAr() {
2020-10-21 19:48:56 +02:00
// converting to fixed AR means we also turn off autoAR
this.setAr({
2021-02-08 22:45:51 +01:00
ratio: this.lastAr.ratio,
type: AspectRatioType.Fixed
2020-10-21 19:48:56 +02:00
});
2019-11-02 02:45:24 +01:00
}
2018-07-10 20:36:12 +02:00
resetLastAr() {
this.lastAr = {type: AspectRatioType.Initial};
2018-05-12 02:51:58 +02:00
}
2018-05-02 17:52:25 +02:00
2025-01-14 01:44:25 +01:00
setStretchMode(stretch: {type: StretchType, ratio?: number}) {
this.stretcher.setStretchMode(stretch);
2018-05-27 21:41:08 +02:00
this.restore();
}
2018-05-02 17:52:25 +02:00
panHandler(event, forcePan) {
if (this.canPan || forcePan) {
if(!this.videoData.player || !this.videoData.player.element) {
2018-09-13 23:47:20 +02:00
return;
}
2024-12-30 03:05:47 +01:00
// don't allow weird floats
2021-10-26 22:19:41 +02:00
this.videoAlignment.x = VideoAlignmentType.Center;
2019-11-02 02:45:24 +01:00
// because non-fixed aspect ratios reset panning:
if (this.lastAr.type !== AspectRatioType.Fixed) {
2020-10-21 19:48:56 +02:00
this.toFixedAr();
}
2019-11-02 02:45:24 +01:00
const player = this.videoData.player.element;
2018-09-13 23:47:20 +02:00
const relativeX = (event.pageX - player.offsetLeft) / player.offsetWidth;
const relativeY = (event.pageY - player.offsetTop) / player.offsetHeight;
2021-10-25 23:11:34 +02:00
2025-05-04 02:18:58 +02:00
this.logger.info({src: 'panHandler', origin: 'mousemove'}, "mousemove.pageX, pageY:", event.pageX, event.pageY, "\nrelativeX/Y:", relativeX, relativeY);
2018-09-13 23:47:20 +02:00
this.setPan(relativeX, relativeY);
}
}
2019-11-02 02:45:24 +01:00
resetPan() {
2021-02-08 22:45:51 +01:00
this.pan = {x: 0, y: 0};
2021-11-03 00:08:34 +01:00
// this.videoAlignment = {x: this.settings.getDefaultVideoAlignment(window.location.hostname), y: VideoAlignmentType.Center};
2019-11-02 02:45:24 +01:00
}
setPan(relativeMousePosX, relativeMousePosY){
2021-10-25 23:11:34 +02:00
// relativeMousePos[X|Y] - on scale from 0 to 1, how close is the mouse to player edges.
// use these values: top, left: 0, bottom, right: 1
if(! this.pan){
2021-02-08 22:45:51 +01:00
this.pan = {x: 0, y: 0};
}
2018-02-04 17:39:26 +01:00
if (this.settings.active.miscSettings.mousePanReverseMouse) {
this.pan.relativeOffsetX = (relativeMousePosX * 1.1) - 0.55;
this.pan.relativeOffsetY = (relativeMousePosY * 1.1) - 0.55;
} else {
this.pan.relativeOffsetX = -(relativeMousePosX * 1.1) + 0.55;
this.pan.relativeOffsetY = -(relativeMousePosY * 1.1) + 0.55;
}
2018-09-13 23:47:20 +02:00
this.restore();
}
2018-05-02 17:52:25 +02:00
2021-10-26 22:19:41 +02:00
setVideoAlignment(videoAlignmentX: VideoAlignmentType, videoAlignmentY?: VideoAlignmentType) {
// if aspect ratio is unset or initial, CSS fixes are inactive by design.
// because of that, we need to set a manual aspect ratio first.
2024-12-30 03:05:47 +01:00
if (!this.lastAr?.ratio) {
this.setAr({
2024-12-30 03:05:47 +01:00
type: AspectRatioType.AutomaticUpdate,
ratio: this.getFileAr()
});
}
2024-12-30 03:05:47 +01:00
if ([AspectRatioType.Reset, AspectRatioType.Initial].includes(this.lastAr.type)) {
if (this.lastAr.ratio) {
this.lastAr.type = AspectRatioType.Fixed;
} else {
this.setAr({
type: AspectRatioType.Fixed,
ratio: this.getFileAr()
});
}
}
2021-10-26 22:19:41 +02:00
this.videoAlignment = {
x: videoAlignmentX ?? VideoAlignmentType.Default,
y: videoAlignmentY ?? VideoAlignmentType.Default
};
this.restore();
}
/**
* Restores aspect ratio to last known aspect ratio
* @returns
*/
2018-06-15 00:33:10 +02:00
restore() {
2021-10-31 23:19:32 +01:00
if (!this.manualZoom) {
2025-05-04 02:18:58 +02:00
this.logger.info('restore', `<rid:${this.resizerId}> attempting to restore aspect ratio`, {'lastAr': this.lastAr} );
2021-10-25 23:11:34 +02:00
2021-10-31 23:19:32 +01:00
// this is true until we verify that css has actually been applied
if(this.lastAr.type === AspectRatioType.Initial){
this.setAr({type: AspectRatioType.Reset});
}
else {
if (this.lastAr?.ratio === null) {
// if this is the case, we do nothing as we have the correct aspect ratio
// throw "Last ar is null!"
return;
}
this.setAr(this.lastAr, this.lastAr)
}
2021-10-31 23:19:32 +01:00
} else {
this.applyScaling({xFactor: this.zoom.scale, yFactor: this.zoom.scaleY});
}
2021-10-31 23:19:32 +01:00
}
2018-05-02 17:52:25 +02:00
reset(){
2025-01-14 01:44:25 +01:00
this.setStretchMode(this.siteSettings.getDefaultOption('stretch') as Stretch);
this.zoom.setZoom(1);
this.resetPan();
this.setAr({type: AspectRatioType.Reset});
this.unsetStyleString();
}
2018-09-13 23:47:20 +02:00
setPanMode(mode) {
if (mode === 'enable') {
this.canPan = true;
} else if (mode === 'disable') {
this.canPan = false;
} else if (mode === 'toggle') {
this.canPan = !this.canPan;
}
}
2025-04-26 23:24:57 +02:00
private _setZoomTimeout;
private _latestSetZoomArgs: any | undefined;
private _SET_ZOOM_RATE_LIMIT_MS = 50;
/**
* Sets zoom level. This function is rate limited, because slider may spam the fuck out of this function call
* @param zoomLevel
* @param axis
* @param noAnnounce
* @returns
*/
setZoom(zoomLevel: number | {x: number, y: number}, noAnnounce?) {
if (this._setZoomTimeout) {
this._latestSetZoomArgs = {zoomLevel, noAnnounce};
return;
}
2021-10-31 23:19:32 +01:00
this.manualZoom = true;
2025-04-26 23:24:57 +02:00
this.zoom.setZoom(zoomLevel);
this._setZoomTimeout = setTimeout(
() => {
clearTimeout(this._setZoomTimeout);
this._setZoomTimeout = undefined;
if (this._latestSetZoomArgs) {
this.setZoom(this._latestSetZoomArgs.zoomLevel);
}
this._latestSetZoomArgs = undefined;
},
this._SET_ZOOM_RATE_LIMIT_MS
);
}
2021-10-25 23:11:34 +02:00
zoomStep(step){
2021-10-31 23:19:32 +01:00
this.manualZoom = true;
this.zoom.zoomStep(step);
}
resetZoom(){
this.zoom.setZoom(1);
2021-10-31 23:19:32 +01:00
this.manualZoom = false;
this.restore();
}
resetCrop(){
this.setAr({type: AspectRatioType.Reset});
}
resetStretch(){
2025-01-14 01:44:25 +01:00
this.stretcher.setStretchMode({type: StretchType.NoStretch});
this.restore();
}
// mostly internal stuff
/**
* Returns the size of the video file _as displayed_ on the screen.
* Consider the following example:
2021-10-25 23:11:34 +02:00
*
* * player dimensions are 2560x1080
* * <video> is child of player
* * <video> has the following css: {width: 100%, height: 100%}
* * video file dimensions are 1280x720
2021-10-25 23:11:34 +02:00
*
* CSS will ensure that the dimensions of <video> tag are equal to the dimension of the
2021-10-25 23:11:34 +02:00
* player element that is, 2560x1080px. This is no bueno, because the browser will upscale
* the video file to take up as much space as it can (without stretching it). This means
* we'll get a 1920x1080 video (as displayed) and a letterbox.
2021-10-25 23:11:34 +02:00
*
* We can't get that number out of anywhere: video.videoWidth will return 1280 (video file
* dimensions) and .offsetWidth (and the likes) will return the <video> tag dimension. Neither
2021-10-25 23:11:34 +02:00
* will return the actual size of video as displayed, which we need in order to calculate the
* extra space to the left and right of the video.
2021-10-25 23:11:34 +02:00
*
* We make the assumption of the
*/
computeVideoDisplayedDimensions() {
const offsetWidth = this.videoData.video.offsetWidth;
const offsetHeight = this.videoData.video.offsetHeight;
const scaleX = offsetWidth / this.videoData.video.videoWidth;
const scaleY = offsetHeight / this.videoData.video.videoHeight;
2021-10-25 23:11:34 +02:00
// if differences between the scale factors are minimal, we presume offsetWidth and
// offsetHeight are the accurate enough for our needs
if (Math.abs(scaleX - scaleY) < 0.02) {
return {
realVideoWidth: offsetWidth,
realVideoHeight: offsetHeight,
marginX: 0,
marginY: 0,
}
}
// if we're still here, we need to calculate real video dimensions
const diffX = Math.abs(scaleY * this.videoData.video.videoWidth - offsetWidth);
const diffY = Math.abs(scaleX * this.videoData.video.videoHeight - offsetHeight);
// in this case, we want to base our real dimensions off scaleX
// otherwise, we want to base it off scaleY
if (diffX < diffY) {
const realHeight = this.videoData.video.videoHeight * scaleX;
return {
realVideoWidth: offsetWidth,
realVideoHeight: realHeight,
marginX: 0,
marginY: (offsetHeight - realHeight) * 0.5
}
} else {
const realWidth = this.videoData.video.videoWidth * scaleY;
return {
realVideoWidth: realWidth,
realVideoHeight: offsetHeight,
marginX: (offsetWidth - realWidth) * 0.5,
marginY: 0
}
}
}
/**
* Sometimes, sites (e.g. new reddit) will guarantee that video fits width of its container
* and let the browser figure out the height through the magic of height:auto. This is bad,
* because our addon generally relies of videos always being 100% of the height of the
* container.
2021-10-25 23:11:34 +02:00
*
* This sometimes leads to a situation where realVideoHeight and realVideoWidth at least
* one of which should be roughly equal to the player width or hight with the other one being
2021-10-25 23:11:34 +02:00
* either smaller or equal are both smaller than player width or height; and sometimes
* rather substantially. Fortunately for us, realVideo[Width|Height] and player dimensions
* never lie, which allows us to calculate the extra scale factor we need.
2021-10-25 23:11:34 +02:00
*
* Returned factor for this function should do fit:contain, not fit:cover.
* @param realVideoWidth real video width
* @param realVideoHeight real video height
* @param playerWidth player width
* @param playerHeight player height
2021-10-25 23:11:34 +02:00
* @param mode whether to
*/
computeAutoHeightCompensationFactor(realVideoWidth: number, realVideoHeight: number, playerWidth: number, playerHeight: number, mode: 'height' | 'width'): number {
const widthFactor = playerWidth / realVideoWidth;
const heightFactor = playerHeight / realVideoHeight;
return mode === 'height' ? heightFactor : widthFactor;
}
2021-04-04 23:19:43 +02:00
private _computeOffsetsRecursionGuard: boolean = false;
2024-12-30 03:05:47 +01:00
computeOffsets(stretchFactors: VideoDimensions, ar?: Ar){
2025-05-04 02:18:58 +02:00
this.logger.info('computeOffsets', `<rid:${this.resizerId}> video will be aligned to `, this.videoAlignment, '— stretch factors before processing:', stretchFactors);
2018-09-13 23:47:20 +02:00
const {realVideoWidth, realVideoHeight, marginX, marginY} = this.computeVideoDisplayedDimensions();
2024-12-30 03:05:47 +01:00
// correct any remaining element size discrepancies (applicable only to certain crop strategies!)
// NOTE: it's possible that we might also need to apply a similar measure for CropPillarbox strategy
// (but we'll wait for bug reports before doing so).
// We also don't compensate for height:auto if height is provided via element style
let autoHeightCompensationFactor;
if (
2021-10-25 23:11:34 +02:00
stretchFactors.cropStrategy === CropStrategy.CropLetterbox
&& (!stretchFactors.styleHeightCompensationFactor || stretchFactors.styleHeightCompensationFactor === 1)
) {
2024-12-30 03:05:47 +01:00
autoHeightCompensationFactor = this.computeAutoHeightCompensationFactor(
realVideoWidth, realVideoHeight,
this.videoData.player.dimensions.width, this.videoData.player.dimensions.height,
'height'
);
stretchFactors.xFactor *= autoHeightCompensationFactor;
stretchFactors.yFactor *= autoHeightCompensationFactor;
}
// NOTE: transform: scale() is self-centering by default.
// we only need to compensate if alignment is set to anything other than center center
// compensation is equal to half the difference between (zoomed) video size and player size.
2019-11-04 23:53:28 +01:00
const translate = {
x: 0,
y: 0
2018-11-16 23:02:56 +01:00
};
2025-01-02 06:32:43 +01:00
const problemStats = getElementStyles(this.video, ['top', 'left', 'transform'], ['transform']);
if (problemStats.left?.css && problemStats.top?.css && problemStats.transform?.css?.includes(`translate(-${problemStats.left.css}, -${problemStats.top.css})`)) {
translate.x -= ~~problemStats.left.pxValue;
translate.y -= ~~problemStats.top.pxValue;
}
// NOTE: manual panning is probably broken now.
// TODO: FIXME:
// (argument could be made that manual panning was also broken before)
const alignXOffset = (realVideoWidth * stretchFactors.xFactor - this.videoData.player.dimensions.width) * 0.5;
const alignYOffset = (realVideoHeight * stretchFactors.yFactor - this.videoData.player.dimensions.height) * 0.5;
2019-11-04 23:53:28 +01:00
2024-06-12 20:29:00 +02:00
if (this.pan?.relativeOffsetX || this.pan?.relativeOffsetY) {
2018-09-13 23:47:20 +02:00
// don't offset when video is smaller than player
if(alignXOffset >= 0 || alignYOffset >= 0) {
translate.x += alignXOffset * this.pan.relativeOffsetX * this.zoom.scale;
translate.y += alignYOffset * this.pan.relativeOffsetY * this.zoom.scale;
2018-09-13 23:47:20 +02:00
}
} else {
2021-11-03 00:08:34 +01:00
// correct horizontal alignment according to the settings
2024-12-30 03:05:47 +01:00
if (!stretchFactors.preventAlignment?.x) {
if (this.videoAlignment.x == VideoAlignmentType.Left) {
translate.x += stretchFactors?.relativeCropLimits?.left ? (this.videoData.player.dimensions.width * stretchFactors.relativeCropLimits.left): alignXOffset;
} else if (this.videoAlignment.x == VideoAlignmentType.Right) {
translate.x -= stretchFactors?.relativeCropLimits?.left ? (this.videoData.player.dimensions.width * stretchFactors.relativeCropLimits.left): alignXOffset
}
}
2021-11-03 00:08:34 +01:00
// correct vertical alignment according to the settings
2024-12-30 03:05:47 +01:00
if (!stretchFactors.preventAlignment?.y) {
if (this.videoAlignment.y == VideoAlignmentType.Top) {
translate.y += stretchFactors?.relativeCropLimits?.top ? (this.videoData.player.dimensions.height * stretchFactors?.relativeCropLimits?.top): alignYOffset;
} else if (this.videoAlignment.y == VideoAlignmentType.Bottom) {
translate.y -= stretchFactors?.relativeCropLimits?.top ? (this.videoData.player.dimensions.height * stretchFactors?.relativeCropLimits?.top): alignYOffset;
}
2021-11-03 00:08:34 +01:00
}
}
2021-10-25 23:11:34 +02:00
2025-05-04 02:18:58 +02:00
this.logger.info(
'computeOffsets',
`<rid:${this.resizerId}> calculated offsets:`,
2021-03-30 01:11:39 +02:00
'\n\n---- elements ----',
'\nplayer element: ', this.videoData.player.element,
'\nvideo element: ', this.videoData.video,
2021-03-30 01:11:39 +02:00
'\n\n---- data in ----',
'\nplayer dimensions: ', {w: this.videoData.player.dimensions.width, h: this.videoData.player.dimensions.height},
'\nvideo dimensions: ', {w: this.videoData.video.offsetWidth, h: this.videoData.video.offsetHeight},
2021-03-29 23:40:34 +02:00
'\nreal video dimensions:', {w: realVideoWidth, h: realVideoHeight},
'\nalign. base offset: ', {alignXOffset, alignYOffset},
'\nauto compensation: ', 'x', autoHeightCompensationFactor,
2021-03-29 23:40:34 +02:00
'\nstretch factors: ', stretchFactors,
'\npan & zoom: ', this.pan, this.zoom.scale,
// '\nwdiff, hdiff: ', wdiff, 'x', hdiff,
// '\nwdiff, hdiffAfterZoom:', wdiffAfterZoom, 'x', hdiffAfterZoom,
2021-03-29 23:40:34 +02:00
'\n\n---- data out ----\n',
'translate:', translate
);
// by the way, let's do a quick sanity check whether video player is doing any fuckies wuckies
2021-10-25 23:11:34 +02:00
// fucky wucky examples:
//
// * video width is bigger than player width AND video height is bigger than player height
// * video width is smaller than player width AND video height is smaller than player height
//
2021-10-25 23:11:34 +02:00
// In both examples, at most one of the two conditions can be true at the same time. If both
// conditions are true at the same time, we need to go 'chiny reckon' and recheck our player
// element. Chances are our video is not getting aligned correctly
2021-10-25 23:11:34 +02:00
if (
2025-04-26 04:23:57 +02:00
(
(this.videoData.video.offsetWidth > this.videoData.player.dimensions.width && this.videoData.video.offsetHeight > this.videoData.player.dimensions.height) ||
(this.videoData.video.offsetWidth < this.videoData.player.dimensions.width && this.videoData.video.offsetHeight < this.videoData.player.dimensions.height)
) && ar?.variant !== ArVariant.Zoom
) {
2025-05-04 02:18:58 +02:00
this.logger.warn('computeOffsets', `<rid:${this.resizerId}> We are getting some incredibly funny results here.\n\n`,
`Video seems to be both wider and taller (or shorter and narrower) than player element at the same time. This is super duper not supposed to happen.\n\n`,
`Player element needs to be checked.`
2025-05-04 02:18:58 +02:00
);
2021-04-04 23:19:43 +02:00
// sometimes this appears to randomly recurse.
// There seems to be no way to reproduce it.
if (! this._computeOffsetsRecursionGuard) {
this._computeOffsetsRecursionGuard = true;
this.videoData.player.trackDimensionChanges();
2021-04-04 23:19:43 +02:00
this._computeOffsetsRecursionGuard = false;
2020-10-21 21:26:00 +02:00
}
}
2020-10-21 21:26:00 +02:00
2021-10-25 23:11:34 +02:00
return translate;
}
2021-10-25 23:11:34 +02:00
2021-03-30 21:55:17 +02:00
//#region css handling
buildStyleArray(existingStyleString, extraStyleString) {
if (existingStyleString) {
const styleArray = existingStyleString.split(";");
if (extraStyleString) {
const extraCss = extraStyleString.split(';');
let dup = false;
for (const ecss of extraCss) {
for (let i in styleArray) {
if (ecss.split(':')[0].trim() === styleArray[i].split(':')[0].trim()) {
dup = true;
styleArray[i] = ecss;
}
if (dup) {
dup = false;
continue;
}
styleArray.push(ecss);
}
}
}
2021-10-25 23:11:34 +02:00
2021-02-18 22:38:32 +01:00
for (let i in styleArray) {
2021-10-25 23:11:34 +02:00
styleArray[i] = styleArray[i].trim();
// some sites do 'top: 50%; left: 50%; transform: <transform>' to center videos.
// we dont wanna, because we already center videos on our own
if (styleArray[i].startsWith("transform:")
2021-10-25 23:11:34 +02:00
|| styleArray[i].startsWith("top:")
|| styleArray[i].startsWith("left:")
|| styleArray[i].startsWith("right:")
|| styleArray[i].startsWith("bottom:")
|| styleArray[i].startsWith("margin")
){
delete styleArray[i];
}
}
return styleArray;
}
return [];
}
buildStyleString(styleArray) {
let styleString = '';
2021-02-18 22:38:32 +01:00
for(let i in styleArray) {
if(styleArray[i]) {
styleString += styleArray[i] + " !important; ";
}
}
return styleString;
}
applyCss(stretchFactors, translate){
2021-10-25 23:11:34 +02:00
// apply extra CSS here. In case of duplicated properties, extraCss overrides
// default styleString
if (! this.video) {
2025-05-04 02:18:58 +02:00
this.logger.warn('applyCss', `<rid:${this.resizerId}> Video went missing, doing nothing.`);
this.videoData.destroy();
return;
}
2025-05-04 02:18:58 +02:00
this.logger.info('applyCss', `<rid:${this.resizerId}> will apply css.`, {stretchFactors, translate});
2021-10-25 23:11:34 +02:00
// save stuff for quick tests (before we turn numbers into css values):
this.currentVideoSettings = {
validFor: this.videoData.player.dimensions,
// videoWidth: dimensions.width,
// videoHeight: dimensions.height
}
let extraStyleString;
try {
extraStyleString = this.siteSettings.data.currentDOMConfig.customCss;
} catch (e) {
// do nothing. It's ok if no special settings are defined for this site, we'll just do defaults
}
const styleArray = this.buildStyleArray('', extraStyleString)
// add remaining elements
if (stretchFactors) {
styleArray.push(`transform: translate(${Math.round(translate.x)}px, ${Math.round(translate.y)}px) scale(${stretchFactors.xFactor}, ${stretchFactors.yFactor}) !important;`);
}
const styleString = `${this.buildStyleString(styleArray)}${extraStyleString || ''}`; // string returned by buildStyleString() should end with ; anyway
// build style string back
this.setStyleString(styleString);
}
setStyleString (styleString) {
this.currentCssValidFor = this.videoData.player.dimensions;
const newCssString = this.prepareCss(styleString);
// inject new CSS or replace existing one
if (!this.userCss) {
2025-05-04 02:18:58 +02:00
this.logger.debug('setStyleString', `<rid:${this.resizerId}> Setting new css: `, newCssString);
this.eventBus.send('inject-css', {cssString: newCssString});
this.userCss = newCssString;
2019-08-24 17:04:53 +02:00
} else if (newCssString !== this.userCss) {
2025-05-04 02:18:58 +02:00
this.logger.debug('setStyleString', `<rid:${this.resizerId}}> Replacing css.\nOld string:`, this.userCss, "\nNew string:", newCssString)
2019-08-24 17:04:53 +02:00
// we only replace css if it
this.eventBus.send('replace-css', {oldCssString: this.userCss, newCssString});
this.userCss = newCssString;
} else {
2025-05-04 02:18:58 +02:00
this.logger.debug('setStyleString', `<rid:${this.resizerId}> Existing css is still valid, doing nothing.`);
2018-02-05 22:46:38 +01:00
}
}
/**
* If no adjustments to crop or stretch are being made, we remove all CSS
* that we have previously injected.
* @param options:
* - options.force: remove our CSS regardless of current crop and stretch options
*/
unsetStyleString(options?: {force: boolean}) {
// check whether it's safe to remove CSS.
if (!options?.force) {
if (
[AspectRatioType.Reset, AspectRatioType.Initial].includes(this.lastAr.type)
) {
return;
}
}
}
2021-03-30 21:55:17 +02:00
//#endregion
2018-02-05 22:46:38 +01:00
}
export default Resizer;