From 892ce1e61ae8f63fca17a70533498a1e69a9a28c Mon Sep 17 00:00:00 2001 From: Juan Treminio Date: Fri, 5 Jun 2026 09:55:43 -0600 Subject: [PATCH 1/2] Adds scrubbing to media comparison modal --- src/Pages/_Generate/GenerateTab.cshtml | 1 + src/wwwroot/css/genpage.css | 23 +++ .../js/genpage/gentab/currentimagehandler.js | 44 ++++-- .../js/genpage/helpers/mediacontrols.js | 131 ++++++++++++++++-- 4 files changed, 183 insertions(+), 16 deletions(-) diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index af57e3ce6..f167d8413 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -211,6 +211,7 @@ 50% +
diff --git a/src/wwwroot/css/genpage.css b/src/wwwroot/css/genpage.css index 9ebe52368..9b80761ec 100644 --- a/src/wwwroot/css/genpage.css +++ b/src/wwwroot/css/genpage.css @@ -854,6 +854,29 @@ body { text-align: left; white-space: nowrap; } +#image_compare_scrub_row { + display: none; + align-self: stretch; + padding: 0 1rem; +} +#image_compare_scrub_row.image_compare_scrub_row_active { + display: block; +} +#image_compare_scrub_row .image_compare_video_controls { + position: static; + opacity: 1; + background: none; + padding: 0; +} +#image_compare_scrub_row .image_compare_video_controls .video-time { + color: var(--text); +} +#image_compare_scrub_row .image_compare_video_controls button { + color: var(--text); +} +#image_compare_scrub_row .image_compare_video_controls .video-progress-inner { + background: var(--range-track-color); +} .image_compare_modal .image_fullview_extra_buttons { display: flex; align-items: center; diff --git a/src/wwwroot/js/genpage/gentab/currentimagehandler.js b/src/wwwroot/js/genpage/gentab/currentimagehandler.js index b3bc967c7..56e6b45d0 100644 --- a/src/wwwroot/js/genpage/gentab/currentimagehandler.js +++ b/src/wwwroot/js/genpage/gentab/currentimagehandler.js @@ -1457,6 +1457,8 @@ class ImageCompareHelper { this.updateMetadataVisibility(); this.updateModeControls(); this.supportedTypes = ['image', 'video']; + this.scrubRow = getRequiredElementById('image_compare_scrub_row'); + this.videoControls = null; } getImgOrContainer() { @@ -1716,6 +1718,9 @@ class ImageCompareHelper { } showComparison(left, right) { + if (this.left?.src != left?.src || this.right?.src != right?.src) { + this.clearVideoControls(); + } this.left = left; this.right = right; let wasAlreadyOpen = this.isOpen(); @@ -1857,6 +1862,7 @@ class ImageCompareHelper { this.showMetadata = false; this.resetViewportState(); this.setStageContent('side', ''); + this.clearVideoControls(); this.metadataDiv.innerHTML = ''; this.updateMetadataVisibility(); this.updateModeControls(); @@ -1919,8 +1925,11 @@ class ImageCompareHelper { render() { this.stopPanning(true); this.updateModeControls(); + let resumeTime = this.videoControls ? this.videoControls.media.currentTime : 0; + let resumePaused = this.videoControls ? this.videoControls.media.paused : false; if (!this.hasSelection()) { this.setStageContent('side', ''); + this.clearVideoControls(); this.metadataDiv.innerHTML = ''; this.updateMetadataVisibility(); this.updateModeControls(); @@ -1930,14 +1939,15 @@ class ImageCompareHelper { this.renderOverlay(); } else if (ImageCompareHelper.modeDefinitions[this.mode].layout == 'single') { - this.setStageContent('single', `
${this.renderMedia(this.left)}
`); + this.setStageContent('single', `
${this.renderMedia(this.left, 'left')}
`); } else { this.setStageContent('side', ` -
${this.renderMedia(this.left)}
-
${this.renderMedia(this.right)}
` +
${this.renderMedia(this.left, 'left')}
+
${this.renderMedia(this.right, 'right')}
` ); } + this.setupVideoControls(resumeTime, resumePaused); let metadataHtml = this.renderMetadataTable(); this.metadataDiv.innerHTML = metadataHtml; this.updateMetadataVisibility(); @@ -1957,14 +1967,32 @@ class ImageCompareHelper { this.setStageContent('overlay', `
-
${this.renderMedia(this.left)}
-
${this.renderMedia(this.right)}
+
${this.renderMedia(this.left, 'left')}
+
${this.renderMedia(this.right, 'right')}
${this.isSlideMode() ? '
' : ''}
` ); } + setupVideoControls(resumeTime, resumePaused) { + this.videoControls?.destroy(); + this.videoControls = null; + let videos = [...this.stage.querySelectorAll('video.image_compare_media')]; + this.scrubRow.classList.toggle('image_compare_scrub_row_active', videos.length > 0); + if (videos.length == 0) { + return; + } + let primary = videos.find(video => video.dataset.compareSide == 'left') ?? videos[0]; + this.videoControls = new CompareVideoControls(videos, primary, this.scrubRow, resumeTime, resumePaused); + } + + clearVideoControls() { + this.videoControls?.destroy(); + this.videoControls = null; + this.scrubRow.classList.remove('image_compare_scrub_row_active'); + } + updateOverlaySplitFromClientPosition(stage, clientX, clientY) { let rect = stage.getBoundingClientRect(); let split; @@ -2107,12 +2135,12 @@ class ImageCompareHelper { this.applyView(); } - renderMedia(media) { + renderMedia(media, side) { return this.renderMediaElement( media.src, 'image_compare_media', - 'alt="Compared media" onload="imageCompareHelper.onImgLoad()"', - 'autoplay loop muted playsinline onloadedmetadata="imageCompareHelper.onImgLoad()"', + `alt="Compared media" data-compare-side="${side}" onload="imageCompareHelper.onImgLoad()"`, + `autoplay loop muted playsinline data-compare-side="${side}" onloadedmetadata="imageCompareHelper.onImgLoad()"`, '', false, ); diff --git a/src/wwwroot/js/genpage/helpers/mediacontrols.js b/src/wwwroot/js/genpage/helpers/mediacontrols.js index 12afd4feb..5e97e610e 100644 --- a/src/wwwroot/js/genpage/helpers/mediacontrols.js +++ b/src/wwwroot/js/genpage/helpers/mediacontrols.js @@ -181,9 +181,8 @@ class VideoControls extends MediaControlsBase { this.createControls(); } - /** Creates the controls UI for the video. */ - createControls() { - let container = this.media.parentElement; + /** Builds the control bar UI (play, time, progress bar, volume). */ + buildControlBar(container) { let controls = createDiv(null, 'video-controls', ` 0:00 @@ -207,10 +206,19 @@ class VideoControls extends MediaControlsBase { this.wirePlayAndVolumeHandlers(); this.media.addEventListener('loadedmetadata', () => this.updateDuration()); this.wirePlaybackProgressRafListeners(); - container.addEventListener('mouseenter', () => { controls.style.opacity = 1; }); - container.addEventListener('mouseleave', () => { controls.style.opacity = 0; }); this.progressBar.addEventListener('click', (e) => this.seek(e)); this.progressBar.addEventListener('mousedown', () => { this.isDragging = true; uiImprover.videoControlDragging = this; }); + if (!this.media.paused) { + this.startProgressAnimation(); + } + } + + /** Creates the controls UI for the video. */ + createControls() { + let container = this.media.parentElement; + this.buildControlBar(container); + container.addEventListener('mouseenter', () => { this.controls.style.opacity = 1; }); + container.addEventListener('mouseleave', () => { this.controls.style.opacity = 0; }); this.media.draggable = true; this.media.addEventListener('dragstart', (e) => { let src = this.media.currentSrc || this.media.src; @@ -220,9 +228,6 @@ class VideoControls extends MediaControlsBase { e.dataTransfer.setData('text/uri-list', src); } }); - if (!this.media.paused) { - this.startProgressAnimation(); - } } /** Whether the progress bar element is still in the document (used by RAF loop in MediaControlsBase). */ @@ -591,3 +596,113 @@ class AudioControls extends MediaControlsBase { this.redrawWaveform(); } } + +/** Video control bar for the image-compare modal: one bar drives every video in the compare stage, and only the primary (left-most) video emits audio. */ +class CompareVideoControls extends VideoControls { + constructor(videos, primary, container, resumeTime = 0, resumePaused = false) { + super(primary); + this.videos = videos; + this.buildControlBar(container); + this.controls.classList.add('image_compare_video_controls'); + for (let video of videos) { + video.muted = video != primary; + if (resumeTime > 0) { + video.currentTime = resumeTime; + } + if (resumePaused) { + video.pause(); + } + video.addEventListener('loadedmetadata', () => { + this.updateDuration(); + this.syncToPrimary(); + }); + } + this.updateDuration(); + this.refreshProgressDisplay(); + this.updateIcons(); + } + + createControls() { + } + + /** Removes the control bar and stops its animation. */ + destroy() { + this.stopProgressAnimation(); + this.controls.remove(); + } + + /** Longest duration among the compared videos, ie the time range the scrub bar spans. */ + spanDuration() { + let duration = 0; + for (let video of this.videos) { + if (isFinite(video.duration)) { + duration = Math.max(duration, video.duration); + } + } + return duration; + } + + /** Seeks all videos to the given time. */ + seekAll(time) { + for (let video of this.videos) { + video.currentTime = time; + } + } + + /** Aligns secondary videos to the primary video's playback position. */ + syncToPrimary() { + let time = this.media.currentTime; + for (let video of this.videos) { + if (video != this.media) { + video.currentTime = time; + } + } + } + + /** Toggles play/pause for all videos together. */ + togglePlay() { + if (this.media.paused) { + for (let video of this.videos) { + video.play().catch(() => {}); + } + } + else { + for (let video of this.videos) { + video.pause(); + } + } + this.updateIcons(); + } + + /** Seeks all videos from a click on the progress bar. */ + seek(e) { + let p = MediaControlsBase.scrubFractionFromClientX(e.clientX, this.progressBar); + let d = this.spanDuration(); + if (p == null || d <= 0) { + return; + } + this.seekAll(p * d); + } + + /** Document-level mousemove while dragging the progress bar. */ + drag(e) { + if (!this.isDragging) { + return; + } + this.seek(e); + this.refreshProgressDisplay(); + } + + /** Updates the progress bar and current-time label to match the primary video. */ + refreshProgressDisplay() { + let d = this.spanDuration(); + let percent = d > 0 ? (this.media.currentTime / d) * 100 : 0; + this.progressFilled.style.width = `${percent}%`; + this.currentTimeEl.textContent = this.formatTime(this.media.currentTime); + } + + /** Updates the duration UI text to the spanned duration. */ + updateDuration() { + this.durationEl.textContent = this.formatTime(this.spanDuration()); + } +} From da014d7940b3523f658d2c2259365b8be5d941f9 Mon Sep 17 00:00:00 2001 From: "Alex \"mcmonkey\" Goodwin" Date: Sat, 6 Jun 2026 22:30:54 -0700 Subject: [PATCH 2/2] fix css --- src/Pages/_Generate/GenerateTab.cshtml | 6 +++--- src/wwwroot/css/genpage.css | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Pages/_Generate/GenerateTab.cshtml b/src/Pages/_Generate/GenerateTab.cshtml index f167d8413..a84ef8113 100644 --- a/src/Pages/_Generate/GenerateTab.cshtml +++ b/src/Pages/_Generate/GenerateTab.cshtml @@ -204,14 +204,14 @@
-