Files
shaka-player/ui/hidden_seek_button.js
T
Álvaro Velad Galván 8d5f33ee65 fix(UI): Improve touch handling for single and double tap actions (#9944)
Co-authored-by: Wojciech Tyczyński <tykus160@gmail.com>
2026-04-08 12:42:20 +02:00

247 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.HiddenSeekButton');
goog.require('shaka.ui.Element');
goog.require('shaka.ui.Icon');
goog.require('shaka.util.Timer');
goog.require('shaka.util.Dom');
goog.requireType('shaka.ui.Controls');
/**
* @extends {shaka.ui.Element}
* @export
*/
shaka.ui.HiddenSeekButton = class extends shaka.ui.Element {
/**
* @param {!HTMLElement} parent
* @param {!shaka.ui.Controls} controls
*/
constructor(parent, controls) {
super(parent, controls);
/** @private {?number} */
this.lastTouchEventTimeSet_ = null;
/** @private {boolean} */
this.triggeredTouchValid_ = false;
/**
* Keeps track of whether the user has moved enough
* to be considered scrolling.
* @private {boolean}
*/
this.hasMoved_ = false;
/**
* Touch-start coordinates for detecting scroll distance.
* @private {?number}
*/
this.touchStartX_ = null;
/** @private {?number} */
this.touchStartY_ = null;
/**
* Timer used to hide the seek button container. In the timers callback,
* if the seek value is still 0s, we interpret it as a single tap
* (play/pause). If not, we perform the seek.
* @private {shaka.util.Timer}
*/
this.hideSeekButtonContainerTimer_ = new shaka.util.Timer(() => {
const seekSeconds = parseInt(this.seekValue_.textContent, 10);
if (seekSeconds === 0) {
this.controls.onContainerClick(/* fromTouchEvent= */ true);
}
this.hideSeekButtonContainer_();
this.controls.resetLastTouchEventTime();
});
/** @protected {!HTMLElement} */
this.seekContainer = shaka.util.Dom.createHTMLElement('div');
this.parent.appendChild(this.seekContainer);
/** @private {!HTMLElement} */
this.seekValue_ = shaka.util.Dom.createHTMLElement('span');
this.seekValue_.textContent = '0s';
this.seekContainer.appendChild(this.seekValue_);
/** @protected {!shaka.ui.Icon} */
this.seekIcon = new shaka.ui.Icon(this.seekContainer);
this.seekIcon.getSvgElement().classList.add(
'shaka-forward-rewind-container-icon');
/** @protected {boolean} */
this.isRewind = false;
// ---------------------------------------------------------------
// TOUCH EVENT LISTENERS for SCROLL vs. TAP DETECTION
// ---------------------------------------------------------------
this.eventManager.listen(this.seekContainer, 'touchstart', (e) => {
const event = /** @type {!TouchEvent} */(e);
event.stopPropagation();
this.onTouchStart_(event);
});
this.eventManager.listen(this.seekContainer, 'touchmove', (e) => {
const event = /** @type {!TouchEvent} */(e);
event.stopPropagation();
this.onTouchMove_(event);
});
this.eventManager.listen(this.seekContainer, 'touchend', (e) => {
const event = /** @type {!TouchEvent} */(e);
event.stopPropagation();
this.onTouchEnd_(event);
});
}
/**
* Called when the user starts touching the screen.
* We record the initial touch coordinates for scroll detection.
* @param {!TouchEvent} event
* @private
*/
onTouchStart_(event) {
// If multiple touches, handle or ignore as needed. Here, we assume
// single-touch.
if (event.touches.length > 0) {
this.touchStartX_ = event.touches[0].clientX;
this.touchStartY_ = event.touches[0].clientY;
}
this.hasMoved_ = false;
}
/**
* Called when the user moves the finger on the screen.
* If the movement exceeds the scroll threshold, we mark this as scrolling.
* @param {!TouchEvent} event
* @private
*/
onTouchMove_(event) {
if (event.touches.length > 0 &&
this.touchStartX_ != null &&
this.touchStartY_ != null) {
const dx = event.touches[0].clientX - this.touchStartX_;
const dy = event.touches[0].clientY - this.touchStartY_;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > shaka.ui.HiddenSeekButton.SCROLL_THRESHOLD_) {
this.hasMoved_ = true;
}
}
}
/**
* Called when the user lifts the finger from the screen.
* If we haven't moved beyond the threshold, treat it as a tap.
* @param {!TouchEvent} event
* @private
*/
onTouchEnd_(event) {
// Ignore this event if the controls are transparent.
// Double-tapping the hidden seek button when the controls are hidden
// should cause the player to go fullscreen, not cause it to
// rewind/fast-forward.
if (!this.controls.isOpaque()) {
event.preventDefault();
event.stopPropagation();
this.controls.onContainerTouch(event);
return;
}
// If user scrolled, don't handle as a tap.
if (this.hasMoved_) {
return;
}
// If any settings menus are open, this tap closes them instead of toggling
// play/seek.
if (this.controls.anySettingsMenusAreOpen()) {
event.preventDefault();
event.stopPropagation();
this.controls.hideSettingsMenus();
return;
}
// Normal tap logic (single vs double tap).
if (this.controls.getConfig().tapSeekDistance > 0 &&
(!this.ad || !this.ad.isLinear())) {
event.preventDefault();
event.stopPropagation();
this.onSeekButtonClick_();
}
}
/**
* Determines whether this tap is a single tap (leading to play/pause)
* or a double tap (leading to a seek). We use a 500 ms window.
* @private
*/
onSeekButtonClick_() {
const tapSeekDistance = this.controls.getConfig().tapSeekDistance;
const doubleTapWindow = shaka.ui.HiddenSeekButton.DOUBLE_TAP_WINDOW_;
if (!this.triggeredTouchValid_) {
// First tap: start our 500 ms "double-tap" timer.
this.triggeredTouchValid_ = true;
this.lastTouchEventTimeSet_ = Date.now();
this.hideSeekButtonContainerTimer_.tickAfter(doubleTapWindow);
} else if ((this.lastTouchEventTimeSet_ +
doubleTapWindow * 1000) > Date.now()) {
// Second tap arrived in time — interpret as a double tap to seek.
this.hideSeekButtonContainerTimer_.stop();
this.lastTouchEventTimeSet_ = Date.now();
let position = parseInt(this.seekValue_.textContent, 10);
if (this.isRewind) {
position -= tapSeekDistance;
} else {
position += tapSeekDistance;
}
this.seekValue_.textContent = position.toString() + 's';
this.seekContainer.style.opacity = '1';
// Restart timer if user might tap again (triple tap).
this.hideSeekButtonContainerTimer_.tickAfter(doubleTapWindow);
this.controls.resetLastTouchEventTime();
}
}
/**
* If the seek value is zero, interpret it as a single tap (play/pause).
* Otherwise, apply the seek and reset.
* @private
*/
hideSeekButtonContainer_() {
const seekSeconds = parseInt(this.seekValue_.textContent, 10);
if (seekSeconds !== 0) {
// Perform the seek.
this.video.currentTime = this.controls.getDisplayTime() + seekSeconds;
}
// Hide and reset.
this.seekContainer.style.opacity = '0';
this.triggeredTouchValid_ = false;
this.seekValue_.textContent = '0s';
}
};
/**
* The amount of time, in seconds, to double-tap detection.
*
* @const {number}
*/
shaka.ui.HiddenSeekButton.DOUBLE_TAP_WINDOW_ = 0.5;
/**
* Minimum distance (px) the finger must move during touch to consider it a
* scroll rather than a tap.
*
* @const {number}
*/
shaka.ui.HiddenSeekButton.SCROLL_THRESHOLD_ = 10;