Files
shaka-player/ui/range_element.js
T
Andy(김규회) 6ee5d38f10 fix(UI): sync seek position with hover timestamp using consistent position calculation (#9818)
## Problem

Previously, click/drag in the seek bar relied on the browser's default
range entry event. The hover preview used 'getValueFromPosition()'
separately. This resulted in a mismatch between the hover timestamp and
the actual search position, This problem was particularly noticeable in
long videos.

## Fix

When the mouse is down, 'e.Use preventDefault()` to block the browser's
default calculation, 'setBarValueForMouse_()' which follows the same
pattern as the existing 'setBarValueForTouch_()' Add a function to
directly call 'getValueFromPosition()' when clicking and dragging I did,
now hover, click, drag, touch all go through the same function, so
timestamp The preview and navigation positions are always synchronized.

In addition, the hard-coded 'thumbWide = 12' is 'thumbRadius = 6'
('range_elements.less') I modified it to the defined '@thumb-size') and
the browser changed the thumb [[rect.left + thumbRadius, rect.right -
thumbRadius]]' only within the range of '[rect.left + thumbRadius]',
Reflected the offset value when the click position was
reverse-calculated as a value.

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Álvaro Velad Galván <ladvan91@hotmail.com>
2026-03-12 11:00:53 -07:00

333 lines
8.5 KiB
JavaScript

/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.RangeElement');
goog.require('shaka.ui.Element');
goog.require('shaka.util.Dom');
goog.require('shaka.util.Timer');
goog.requireType('shaka.ui.Controls');
/**
* A range element, built to work across browsers.
*
* In particular, getting styles to work right on IE requires a specific
* structure.
*
* This also handles the case where the range element is being manipulated and
* updated at the same time. This can happen when seeking during playback or
* when casting.
*
* @implements {shaka.extern.IUIRangeElement}
* @export
*/
shaka.ui.RangeElement = class extends shaka.ui.Element {
/**
* @param {!HTMLElement} parent
* @param {!shaka.ui.Controls} controls
* @param {!Array<string>} containerClassNames
* @param {!Array<string>} barClassNames
*/
constructor(parent, controls, containerClassNames, barClassNames) {
super(parent, controls);
/**
* This container is to support IE 11. See detailed notes in
* less/range_elements.less for a complete explanation.
* @protected {!HTMLElement}
*/
this.container = shaka.util.Dom.createHTMLElement('div');
this.container.classList.add('shaka-range-container');
this.container.classList.add(...containerClassNames);
/** @private {boolean} */
this.isChanging_ = false;
/** @private {boolean} */
this.isMouseChanging_ = false;
/** @protected {!HTMLInputElement} */
this.bar =
/** @type {!HTMLInputElement} */ (document.createElement('input'));
/** @private {shaka.util.Timer} */
this.endFakeChangeTimer_ = new shaka.util.Timer(() => {
this.onChangeEnd();
this.isChanging_ = false;
});
this.bar.classList.add('shaka-range-element');
this.bar.classList.add(...barClassNames);
this.bar.type = 'range';
this.bar.step = 'any';
this.bar.min = '0';
this.bar.max = '1';
this.bar.value = '0';
this.bar.disabled = !this.controls.isOpaque();
this.container.appendChild(this.bar);
this.parent.appendChild(this.container);
this.showingUITimer_ = new shaka.util.Timer(() => {
this.bar.disabled = false;
});
this.eventManager.listen(this.controls, 'showingui', () => {
this.showingUITimer_.tickAfter(/* seconds= */ 0);
});
this.eventManager.listen(this.controls, 'hidingui', () => {
this.showingUITimer_.stop();
this.bar.disabled = true;
});
this.eventManager.listen(this.bar, 'mousedown', (e) => {
if (!this.bar.disabled) {
// Prevent native range update to use getValueFromPosition()
// consistently with the hover preview.
e.preventDefault();
this.bar.focus();
this.isChanging_ = true;
this.isMouseChanging_ = true;
this.setBarValueForMouse_(e);
this.onChangeStart();
this.onChange();
e.stopPropagation();
}
});
this.eventManager.listen(document, 'mousemove', (e) => {
if (this.isMouseChanging_) {
this.setBarValueForMouse_(e);
this.onChange();
}
});
this.eventManager.listen(document, 'mouseup', (e) => {
if (this.isMouseChanging_) {
this.isMouseChanging_ = false;
if (this.isChanging_) {
this.isChanging_ = false;
this.setBarValueForMouse_(e);
this.onChangeEnd();
}
}
});
this.eventManager.listen(this.bar, 'touchstart', (e) => {
if (!this.bar.disabled) {
this.isChanging_ = true;
this.setBarValueForTouch_(e);
this.onChangeStart();
e.stopPropagation();
}
});
this.eventManager.listen(this.bar, 'input', () => {
this.onChange();
});
this.eventManager.listen(this.bar, 'touchmove', (e) => {
if (this.isChanging_) {
this.setBarValueForTouch_(e);
this.onChange();
e.stopPropagation();
}
});
this.eventManager.listen(this.bar, 'touchend', (e) => {
if (this.isChanging_) {
this.isChanging_ = false;
this.setBarValueForTouch_(e);
this.onChangeEnd();
e.stopPropagation();
}
});
this.eventManager.listen(this.bar, 'touchcancel', (e) => {
if (this.isChanging_) {
this.isChanging_ = false;
this.setBarValueForTouch_(e);
this.onChangeEnd();
e.stopPropagation();
}
});
this.eventManager.listen(this.bar, 'mouseup', (e) => {
if (this.isChanging_) {
this.isChanging_ = false;
this.isMouseChanging_ = false;
this.setBarValueForMouse_(e);
this.onChangeEnd();
e.stopPropagation();
}
});
this.eventManager.listen(this.bar, 'blur', () => {
if (this.isChanging_) {
this.isChanging_ = false;
this.isMouseChanging_ = false;
this.onChangeEnd();
}
});
this.eventManager.listen(this.bar, 'contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
/** @override */
release() {
if (this.endFakeChangeTimer_) {
this.endFakeChangeTimer_.stop();
this.endFakeChangeTimer_ = null;
}
super.release();
}
/**
* @override
* @export
*/
setRange(min, max) {
this.bar.min = min;
this.bar.max = max;
}
/**
* Called when user interaction begins.
* To be overridden by subclasses.
* @override
* @export
*/
onChangeStart() {}
/**
* Called when a new value is set by user interaction.
* To be overridden by subclasses.
* @override
* @export
*/
onChange() {}
/**
* Called when user interaction ends.
* To be overridden by subclasses.
* @override
* @export
*/
onChangeEnd() {}
/**
* Called to implement keyboard-based changes, where this is no clear "end".
* This will simulate events like onChangeStart(), onChange(), and
* onChangeEnd() as appropriate.
*
* @override
* @export
*/
changeTo(value) {
if (!this.isChanging_) {
this.isChanging_ = true;
this.onChangeStart();
}
const min = parseFloat(this.bar.min);
const max = parseFloat(this.bar.max);
if (value > max) {
this.bar.value = max;
} else if (value < min) {
this.bar.value = min;
} else {
this.bar.value = value;
}
this.onChange();
this.endFakeChangeTimer_.tickAfter(/* seconds= */ 0.5);
}
/**
* @override
* @export
*/
getValue() {
return parseFloat(this.bar.value);
}
/**
* @override
* @export
*/
setValue(value) {
// The user interaction overrides any external values being pushed in.
if (this.isChanging_) {
return;
}
this.bar.value = value;
}
/**
* Converts an X position within the element into a range input value.
* @param {number} clientX
* @return {number}
*/
getValueFromPosition(clientX) {
const rect = this.bar.getBoundingClientRect();
const min = parseFloat(this.bar.min);
const max = parseFloat(this.bar.max);
const step = parseFloat(this.bar.step) || 1;
// thumbRadius is half of @thumb-size, as defined in range_elements.less.
// The browser renders the thumb only within the movement range
// [rect.left + thumbRadius, rect.right - thumbRadius], so we must apply
// the same offset when mapping a click position back to a value.
// Note: thumbRadius must stay in sync with @thumb-size in
// range_elements.less.
const thumbRadius = 6; // half of @thumb-size in range_elements.less
const minX = rect.left + thumbRadius;
const maxX = rect.right - thumbRadius;
const clampedX = Math.max(minX, Math.min(maxX, clientX));
const percent = (clampedX - minX) / (maxX - minX);
let value = min + percent * (max - min);
value = Math.round((value - min) / step) * step + min;
value = Math.min(max, Math.max(min, value));
return value;
}
/**
* Synchronize the mouse position with the range value.
* @param {Event} event
* @private
*/
setBarValueForMouse_(event) {
this.bar.value = this.getValueFromPosition(
/** @type {MouseEvent} */ (event).clientX);
}
/**
* Synchronize the touch position with the range value.
* Comes in handy on iOS, where users have to grab the handle in order
* to start seeking.
* @param {Event} event
* @private
*/
setBarValueForTouch_(event) {
event.preventDefault();
const changedTouch = /** @type {TouchEvent} */ (event).changedTouches[0];
this.bar.value = this.getValueFromPosition(changedTouch.clientX);
}
};