From b1a44cf0cc34603aa99ca65a47b7d2e63c70c8f2 Mon Sep 17 00:00:00 2001 From: JaredTherriault Date: Thu, 18 Apr 2024 17:58:33 -0700 Subject: [PATCH] Add playbackRate video option, improve menu widget options -Add option for playback rate (speed) -Add hotkeys for changing playback rate ( , and . ) -Add labeled number input widget -Inherit labeled widget types from base class -Add option for onchange to above base class -Clamp numbers when setting numeric values on videos -Add some helper functions to video elements -Remove bPropagateOnChange, roll functionality into forEachElement delegate -Add widgetType parameter to inform the widget type to be created -Removed unnecessary and incorrect comments --- web/js/ImageDrawer/ImageElements.js | 48 ++++++++--- web/js/common/EventManager.js | 20 ++++- web/js/common/ImageAndVideoObserver.js | 2 +- web/js/common/SettingsManager.js | 108 +++++++++++++++++++------ web/js/common/VideoOptions.js | 37 +++++---- 5 files changed, 161 insertions(+), 54 deletions(-) diff --git a/web/js/ImageDrawer/ImageElements.js b/web/js/ImageDrawer/ImageElements.js index 69d3309..fb6ec8e 100644 --- a/web/js/ImageDrawer/ImageElements.js +++ b/web/js/ImageDrawer/ImageElements.js @@ -6,7 +6,7 @@ import { getPngMetadata } from "/scripts/pnginfo.js"; import { getMaxZIndex, createDarkContainer, copyToClipboard, - isValid, getCurrentSecondsFromEpoch + isValid, getCurrentSecondsFromEpoch, clamp } from "../common/Utilities.js"; import { getLastMousePosition, isPointerDown } from "../common/EventManager.js"; @@ -574,8 +574,27 @@ export async function createImageElementFromFileInfo(fileInfo) { } }); - imageElement.forceLoad = function () { + img.forceLoad = function () { img.src = img.dataSrc; + + if (bIsVideoFormat) { + img.setPlaybackRate(setting_VideoPlaybackOptions.value.defaultPlaybackRate); // This gets reset when src is reset + } + } + + img.initVideo = function () { + + img.type = fileInfo.file?.format || undefined; + img.autoplay = false; // Start false, will autoplay via observer + img.loop = setting_VideoPlaybackOptions.value.loop; + img.controls = setting_VideoPlaybackOptions.value.controls; + img.muted = setting_VideoPlaybackOptions.value.muted; + img.setVolume(setting_VideoPlaybackOptions.value.defaultVolume); + img.setPlaybackRate(setting_VideoPlaybackOptions.value.defaultPlaybackRate); + } + + imageElement.forceLoad = function () { + img.forceLoad(); } if (fileInfo.bShouldForceLoad) { @@ -654,15 +673,6 @@ export async function createImageElementFromFileInfo(fileInfo) { if (bIsVideoFormat) { - img.type = fileInfo.file?.format || undefined; - img.autoplay = false; // Start false, will autoplay via observer - img.loop = setting_VideoPlaybackOptions.value.loop; - img.controls = setting_VideoPlaybackOptions.value.controls; - img.muted = setting_VideoPlaybackOptions.value.muted; - img.volume = setting_VideoPlaybackOptions.value.defaultVolume / 100; // Slider is 1-100, volume is 0-1 - - imageElement.bIsVideoFormat = bIsVideoFormat; - img.togglePlayback = async function () { if (!img.pause || !img.play || !('paused' in img)) { return; } @@ -679,6 +689,10 @@ export async function createImageElementFromFileInfo(fileInfo) { } } + img.setVolume = function (percentage) { + img.volume = clamp(percentage / 100, 0.0, 1.0); // Slider is 1-100, volume is 0-1 + } + img.toggleFullscreen = function () { if (!document.fullscreenElement) { img.requestFullscreen(); @@ -687,15 +701,19 @@ export async function createImageElementFromFileInfo(fileInfo) { } } + img.setPlaybackRate = function (rate) { + img.playbackRate = clamp(rate, 0.05, 100.00); + } + img.seekVideo = function (delta) { if (!img.duration || !img.currentTime) { return; } const maxTime = img.duration; const currentTime = img.currentTime; - const seekStep = Math.min(10, Math.max(1, maxTime / 100)); // Seek multiplier + const seekStep = clamp(maxTime / 100, 1, 10); // Seek multiplier: 1 sec min / 1% of video / 10 sec max let newTime = currentTime + (delta * seekStep); - newTime = Math.max(0, Math.min(maxTime, newTime)); // Clamp within valid range + newTime = clamp(newTime, 0, maxTime); // Clamp within valid range img.currentTime = newTime; // Seek the video to the new time }; @@ -714,6 +732,10 @@ export async function createImageElementFromFileInfo(fileInfo) { img.seekVideo(scrollDelta); } }); + + img.initVideo(); + + imageElement.bIsVideoFormat = bIsVideoFormat; } aElement.appendChild(img); diff --git a/web/js/common/EventManager.js b/web/js/common/EventManager.js index 423614a..9eae0c6 100644 --- a/web/js/common/EventManager.js +++ b/web/js/common/EventManager.js @@ -63,7 +63,7 @@ document.addEventListener("keydown", async (event) => { if (elementUnderPointer && elementUnderPointer.toggleMute) { event.preventDefault(); - elementUnderPointer.toggleMute() + elementUnderPointer.toggleMute(); } } else if (event.key == 'f') { // Toggle fullscreen const elementUnderPointer = getElementUnderPointer(); @@ -71,7 +71,23 @@ document.addEventListener("keydown", async (event) => { if (elementUnderPointer && elementUnderPointer.toggleFullscreen) { event.preventDefault(); - elementUnderPointer.toggleFullscreen() + elementUnderPointer.toggleFullscreen(); + } + } else if (event.key == ',') { // Decrease playback rate + const elementUnderPointer = getElementUnderPointer(); + + if (elementUnderPointer && elementUnderPointer.playbackRate && elementUnderPointer.setPlaybackRate) { + event.preventDefault(); + + elementUnderPointer.setPlaybackRate(elementUnderPointer.playbackRate - 0.05); + } + } else if (event.key == '.') { // Increase playback rate + const elementUnderPointer = getElementUnderPointer(); + + if (elementUnderPointer && elementUnderPointer.playbackRate && elementUnderPointer.setPlaybackRate) { + event.preventDefault(); + + elementUnderPointer.setPlaybackRate(elementUnderPointer.playbackRate + 0.05); } } }); diff --git a/web/js/common/ImageAndVideoObserver.js b/web/js/common/ImageAndVideoObserver.js index ed48e41..d11e157 100644 --- a/web/js/common/ImageAndVideoObserver.js +++ b/web/js/common/ImageAndVideoObserver.js @@ -67,7 +67,7 @@ const imageAndVideoObserver = new IntersectionObserver((entries) => { // Check if the video is intersecting with the viewport if (entry.isIntersecting) { if (!element.src || element.src === '') { - element.src = element.dataSrc; + element.forceLoad(); if (element.tagName !== 'VIDEO') { unobserveVisualElement(element); diff --git a/web/js/common/SettingsManager.js b/web/js/common/SettingsManager.js index bfb289b..3a5f87e 100644 --- a/web/js/common/SettingsManager.js +++ b/web/js/common/SettingsManager.js @@ -323,17 +323,10 @@ export function createVideoPlaybackOptionsMenuWidgets(menu) { const infos = new info_VideoPlaybackOptions(); - function setGenericSettingOnVisualElementsInImageList(propertyName, propertyValue, info) { + function callForEachCallbackOnEachElementInImageList(propertyName, propertyValue, info) { for (let child of getImageListChildren()) { for (let element of getVideoElements(child)) { - - if (info.bPropagateOnChange) { - element[propertyName] = propertyValue; - } - - if (info.forEachElement) { - info.forEachElement(element, propertyValue); - } + info.forEachElement(element, propertyName, propertyValue); } } } @@ -345,8 +338,8 @@ export function createVideoPlaybackOptionsMenuWidgets(menu) { // If requested, set similarly named properties on image list const info = infos[propertyName]; - if (info.bPropagateOnChange || info.forEachElement) { - setGenericSettingOnVisualElementsInImageList(propertyName, newValue, info); + if (info.forEachElement) { + callForEachCallbackOnEachElementInImageList(propertyName, newValue, info); } } @@ -354,8 +347,9 @@ export function createVideoPlaybackOptionsMenuWidgets(menu) { Object.entries(new options_VideoPlayback()).forEach(([propertyName, propertyValue]) => { // In case we're using an old serialization, use the serialized value or default const propertyValueToUse = propertyName in setting_VideoPlaybackOptions.value ? setting_VideoPlaybackOptions.value[propertyName] : propertyValue; + const info = infos[propertyName]; let widget; - if (typeof propertyValueToUse === 'boolean') { + if (info.widgetType === 'checkbox') { let options = new options_LabeledCheckboxToggle(); options.id = `VideoPlaybackOptions.${propertyName}` options.checked = propertyValueToUse; @@ -366,18 +360,35 @@ export function createVideoPlaybackOptionsMenuWidgets(menu) { oninput(propertyName, newValue); } widget = createLabeledCheckboxToggle(options); - } else if (typeof propertyValueToUse === 'number') { + } else if (info.widgetType === 'range') { let options = new options_LabeledSliderRange(); options.id = `VideoPlaybackOptions.${propertyName}` options.value = propertyValueToUse; options.labelTextContent = propertyName; options.bIncludeValueLabel = false; + options.min = info?.min ? info.min : options.min; + options.max = info?.max ? info.max : options.max; + options.step = info?.step ? info.step : options.step; options.oninput = (e) => { // This config setting is a class instance, so we need to take a few extra steps to serialize it const newValue = e.target.valueAsNumber; oninput(propertyName, newValue); } widget = createLabeledSliderRange(options); + } else if (info.widgetType === 'number') { + let options = new options_LabeledNumberInput(); + options.id = `VideoPlaybackOptions.${propertyName}` + options.value = propertyValueToUse; + options.labelTextContent = propertyName; + options.min = info?.min ? info.min : options.min; + options.max = info?.max ? info.max : options.max; + options.step = info?.step ? info.step : options.step; + options.onchange = (e) => { // Use onchange here because the user can type a value in, onchange requires commit + // This config setting is a class instance, so we need to take a few extra steps to serialize it + const newValue = e.target.valueAsNumber; + oninput(propertyName, newValue); + } + widget = createLabeledNumberInput(options); } widget.title = infos[propertyName]?.tooltip || ''; menu.appendChild(widget); @@ -394,17 +405,33 @@ export function createVideoPlaybackOptionsFlyout() { return flyout; } -export class options_LabeledSliderRange { +class options_BaseLabeledWidget { + id = undefined; labelTextContent = undefined; + oninput = undefined; + onchange = undefined; + + bindEvents(widget) { + if (this.oninput) { + widget.oninput = this.oninput; + } + + if (this.onchange) { + widget.onchange = this.onchange; + } + + return widget; + } +} + +export class options_LabeledSliderRange extends options_BaseLabeledWidget { bIncludeValueLabel = true; bPrependValueLabel = false; valueLabelFractionalDigits = 0; - id = undefined; value = 0; min = 0; max = 100 step = 1; - oninput = undefined; } export function createLabeledSliderRange(options = new options_LabeledSliderRange()) { @@ -441,10 +468,11 @@ export function createLabeledSliderRange(options = new options_LabeledSliderRang value: options.value, min: options.min, max: options.max, - step: options.step, - oninput: options.oninput + step: options.step }); + options.bindEvents(MainElement); + let OuterElement = $el('div', { style: { display: 'flex', @@ -467,11 +495,42 @@ export function createLabeledSliderRange(options = new options_LabeledSliderRang return OuterElement; } -export class options_LabeledCheckboxToggle { - labelTextContent = undefined; - id = undefined; +export class options_LabeledNumberInput extends options_BaseLabeledWidget { + value = 0; + min = 0; + max = 100 + step = 1; +} + +export function createLabeledNumberInput(options = new options_LabeledNumberInput()) { + + let MainElement = $el('input', { + id: options.id, + type: 'number', + value: options.value, + min: options.min, + max: options.max, + step: options.step + }); + + options.bindEvents(MainElement); + + let OuterElement = $el('div', { + style: { + display: 'flex', + alignItems: 'center', + gap: '5%' + } + }); + + OuterElement.appendChild($el('label', { textContent: options.labelTextContent })); + OuterElement.appendChild(MainElement); + + return OuterElement; +} + +export class options_LabeledCheckboxToggle extends options_BaseLabeledWidget { checked = false; - oninput = undefined; } export function createLabeledCheckboxToggle(options = new options_LabeledCheckboxToggle()) { @@ -479,10 +538,11 @@ export function createLabeledCheckboxToggle(options = new options_LabeledCheckbo let MainElement = $el('input', { id: options.id, type: 'checkbox', - checked: options.checked, - oninput: options.oninput + checked: options.checked }); + options.bindEvents(MainElement); + let OuterElement = $el('div', { style: { display: 'flex', diff --git a/web/js/common/VideoOptions.js b/web/js/common/VideoOptions.js index 4ba0a6c..c52ef97 100644 --- a/web/js/common/VideoOptions.js +++ b/web/js/common/VideoOptions.js @@ -1,20 +1,21 @@ export class options_VideoPlayback { - autoplay = false; loop = true; controls = true; muted = true; useWheelSeek = true; invertWheelSeek = true; defaultVolume = 50; + autoplay = false; loop = true; controls = true; muted = true; useWheelSeek = true; invertWheelSeek = true; defaultVolume = 50; defaultPlaybackRate = 1.00; }; export class info_VideoPlaybackOptions { autoplay = { tooltip: 'Should the videos automatically play as they scroll into view? Does not apply to animated images, only video types.', - bPropagateOnChange: true, forEachElement: forEachElement_Autoplay }; - loop = { tooltip: 'Should the videos automatically play again when the end is reached?', bPropagateOnChange: true, forEachElement: null }; - controls = { tooltip: 'Should controls such as play/pause, volume, etc be displayed on the video?', bPropagateOnChange: true, forEachElement: null }; - muted = { tooltip: 'Should videos play without audio?', bPropagateOnChange: true, forEachElement: null }; - useWheelSeek = { tooltip: 'Should videos seek when mousing over a video and using the scroll wheel?', bPropagateOnChange: false, forEachElement: null }; + forEachElement: forEachElement_Autoplay, widgetType: 'checkbox' }; + loop = { tooltip: 'Should the videos automatically play again when the end is reached?', forEachElement: forEachElement_genericPropagatation, widgetType: 'checkbox' }; + controls = { tooltip: 'Should controls such as play/pause, volume, etc be displayed on the video?', forEachElement: forEachElement_genericPropagatation, widgetType: 'checkbox' }; + muted = { tooltip: 'Should videos play without audio?', forEachElement: forEachElement_genericPropagatation, widgetType: 'checkbox' }; + useWheelSeek = { tooltip: 'Should videos seek when mousing over a video and using the scroll wheel?', forEachElement: forEachElement_genericPropagatation, widgetType: 'checkbox' }; invertWheelSeek = { tooltip: 'Should scroll wheel seeking be inverted? (If true, scrolling up will seek forward. If false, will seek backward)', - bPropagateOnChange: false, forEachElement: null }; - defaultVolume = { tooltip: 'The default volume at which videos should play if not muted.', bPropagateOnChange: false, forEachElement: forEachElement_Volume }; + forEachElement: forEachElement_genericPropagatation, widgetType: 'checkbox' }; + defaultVolume = { tooltip: 'The default volume at which videos should play if not muted', forEachElement: forEachElement_Volume, widgetType: 'range' }; + defaultPlaybackRate = { tooltip: 'The default rate at which videos should play', forEachElement: forEachElement_playbackRate, widgetType: 'number', min: 0.01, max: 100.00, step: 0.01 }; }; -function forEachElement_Autoplay(element, propertyValue) { +function forEachElement_Autoplay(element, propertyName, propertyValue) { // Pause anything currently playing if autoplay is false // We don't want to play just anything, we'll let observer take care of that if (!propertyValue && element.pause && 'paused' in element && !element.paused) { @@ -22,10 +23,18 @@ function forEachElement_Autoplay(element, propertyValue) { } } -function forEachElement_Volume(element, propertyValue) { - // Pause anything currently playing if autoplay is false - // We don't want to play just anything, we'll let observer take care of that - if (propertyValue && 'volume' in element) { - element.volume = propertyValue / 100; // Slider is 1-100, volume is 0-1 +function forEachElement_genericPropagatation(element, propertyName, propertyValue) { + element[propertyName] = propertyValue; +} + +function forEachElement_Volume(element, propertyName, propertyValue) { + if (propertyValue && element.setVolume) { + element.setVolume(propertyValue); + } +} + +function forEachElement_playbackRate(element, propertyName, propertyValue) { + if (propertyValue && element.setPlaybackRate) { + element.setPlaybackRate(propertyValue); } }