Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(UI): Add thumbnails to the UI #5502

Merged
merged 10 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/controls.less
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@import "less/overflow_menu.less";
@import "less/ad_controls.less";
@import "less/tooltip.less";
@import "less/thumbnails.less";
@import (css, inline) "https://fonts.googleapis.com/css?family=Roboto";
// NOTE: Working around google/material-design-icons#958
@import (css, inline) "https://fonts.googleapis.com/icon?family=Material+Icons+Round";
26 changes: 26 additions & 0 deletions ui/less/thumbnails.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#shaka-player-ui-thumbnail-container {
background-color: black;
border: 1px solid black;
box-shadow: 0 8px 8px 0 rgb(0 0 0 / 50%);
min-width: 150px;
overflow: hidden;
position: absolute;
visibility: hidden;
width: 15%;
z-index: 1;

#shaka-player-ui-thumbnail-image {
position: absolute;
}

#shaka-player-ui-thumbnail-time {
background-color: rgb(0 0 0 / 50%);
bottom: 0;
color: white;
font-size: 16px;
left: 0;
position: absolute;
right: 0;
text-align: center;
}
}
277 changes: 277 additions & 0 deletions ui/seek_bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,49 @@ shaka.ui.SeekBar = class extends shaka.ui.RangeElement {
*/
this.wasPlaying_ = false;


/** @private {!HTMLElement} */
this.thumbnailContainer_ = shaka.util.Dom.createHTMLElement('div');
this.thumbnailContainer_.id = 'shaka-player-ui-thumbnail-container';

/** @private {!HTMLImageElement} */
this.thumbnailImage_ = /** @type {!HTMLImageElement} */ (
shaka.util.Dom.createHTMLElement('img'));
this.thumbnailImage_.id = 'shaka-player-ui-thumbnail-image';
this.thumbnailImage_.draggable = false;

/** @private {!HTMLElement} */
this.thumbnailTime_ = shaka.util.Dom.createHTMLElement('div');
this.thumbnailTime_.id = 'shaka-player-ui-thumbnail-time';

this.thumbnailContainer_.appendChild(this.thumbnailImage_);
this.thumbnailContainer_.appendChild(this.thumbnailTime_);
this.container.appendChild(this.thumbnailContainer_);

/**
* Use to see is the bar is moving with touch o keys.
avelad marked this conversation as resolved.
Show resolved Hide resolved
*
* @private {boolean}
*/
this.isMoving_ = false;

/**
* Thumbnails cache.
*
* @private {Object}
*/
this.thumbnails_ = {};


/**
* The timer is activated to hide the thumbnail.
*
* @private {shaka.util.Timer}
*/
this.hideThumbnailTimer_ = new shaka.util.Timer(() => {
this.hideThumbnail_();
});

/** @private {!Array.<!shaka.extern.AdCuePoint>} */
this.adCuePoints_ = [];

Expand Down Expand Up @@ -120,6 +163,97 @@ shaka.ui.SeekBar = class extends shaka.ui.RangeElement {
this.onAdCuePointsChanged_();
});

this.eventManager.listen(this.bar, 'mousemove', (event) => {
if (!this.player.getImageTracks().length) {
this.hideThumbnail_();
return;
}
const rect = this.bar.getBoundingClientRect();
const min = parseFloat(this.bar.min);
const max = parseFloat(this.bar.max);
// Pixels from the left of the range element
const mousePosition = event.clientX - rect.left;
// Pixels per unit value of the range element.
const scale = (max - min) / rect.width;
// Mouse position in units, which may be outside the allowed range.
const value = Math.round(min + scale * mousePosition);
// Show Thumbnail
this.showThumbnail_(mousePosition, value);
});

this.eventManager.listen(this.bar, 'keydown', (event) => {
this.hideThumbnailTimer_.stop();
avelad marked this conversation as resolved.
Show resolved Hide resolved
const leftKeyCode = 37;
avelad marked this conversation as resolved.
Show resolved Hide resolved
const rightKeyCode = 39;
// Left and Right only
if (event.keyCode != leftKeyCode && event.keyCode != rightKeyCode) {
return;
}
this.isMoving_ = true;
const min = parseFloat(this.bar.min);
const max = parseFloat(this.bar.max);
if (event.keyCode == leftKeyCode) {
this.bar.value = parseFloat(this.bar.value) - (max - min) * 0.025;
} else if (event.keyCode == rightKeyCode) {
this.bar.value = parseFloat(this.bar.value) + (max - min) * 0.025;
}
const rect = this.bar.getBoundingClientRect();
const value = Math.round(this.bar.value);
const scale = (max - min) / rect.width;
const position = (value - min) / scale;
this.showThumbnail_(position, value);
event.preventDefault();
});

this.eventManager.listen(this.bar, 'keyup', () => {
if (this.isMoving_) {
avelad marked this conversation as resolved.
Show resolved Hide resolved
this.isMoving_ = false;
this.hideThumbnailTimer_.stop();
this.hideThumbnailTimer_.tickAfter(/* seconds= */ 0.25);
}
});

if (navigator.maxTouchPoints > 0) {
const touchMove = (event) => {
this.isMoving_ = true;
avelad marked this conversation as resolved.
Show resolved Hide resolved
const rect = this.bar.getBoundingClientRect();
const min = parseFloat(this.bar.min);
const max = parseFloat(this.bar.max);
// Pixels from the left of the range element
const touchPosition = event.changedTouches[0].clientX - rect.left;
// Pixels per unit value of the range element.
const scale = (max - min) / rect.width;
// Mouse position in units, which may be outside the allowed range.
const value = Math.round(min + scale * touchPosition);
// Show Thumbnail
this.showThumbnail_(touchPosition, value);
// Update the bar
this.bar.value = value;
event.preventDefault();
};
const touchEnd = () => {
if (this.isMoving_) {
this.isMoving_ = false;
this.hideThumbnail_();
}
};
this.eventManager.listen(this.bar, 'touchstart', touchMove);
this.eventManager.listen(this.bar, 'touchmove', touchMove);
this.eventManager.listen(this.bar, 'touchend', touchEnd);
this.eventManager.listen(this.bar, 'touchcancel', touchEnd);
}

this.eventManager.listen(this.bar, 'blur', () => {
if (this.isMoving_) {
this.isMoving_ = false;
this.hideThumbnail_();
}
});

this.eventManager.listen(this.container, 'mouseleave', () => {
this.hideThumbnail_();
avelad marked this conversation as resolved.
Show resolved Hide resolved
});

// Initialize seek state and label.
this.setValue(this.video.currentTime);
this.update();
Expand Down Expand Up @@ -376,6 +510,149 @@ shaka.ui.SeekBar = class extends shaka.ui.RangeElement {
updateAriaLabel_() {
this.bar.ariaLabel = this.localization.resolve(shaka.ui.Locales.Ids.SEEK);
}


/**
* @private
*/
async showThumbnail_(pixelPosition, value) {
const thumbnailTrack = this.getThumbnailTrack_();
if (!thumbnailTrack) {
this.hideThumbnail_();
return;
}
if (value < 0) {
value = 0;
}
let thumbnail = this.thumbnails_[value];
const seekRange = this.player.seekRange();
if (this.player.isLive()) {
this.thumbnailTime_.textContent =
'-' + this.timeFormater_(seekRange.end - value);
avelad marked this conversation as resolved.
Show resolved Hide resolved
} else {
this.thumbnailTime_.textContent = this.timeFormater_(value);
}
const offsetTop = -10;
const width = this.thumbnailContainer_.clientWidth;
let height = Math.floor(width * 9 / 16);
this.thumbnailContainer_.style.height = height + 'px';
this.thumbnailContainer_.style.top = -(height - offsetTop) + 'px';
const leftPosition = Math.min(this.bar.offsetWidth - width,
Math.max(0, pixelPosition - (width / 2)));
this.thumbnailContainer_.style.left = leftPosition + 'px';
this.thumbnailContainer_.style.visibility = 'visible';
if (!thumbnail) {
avelad marked this conversation as resolved.
Show resolved Hide resolved
const playerValue = Math.max(Math.ceil(seekRange.start),
Math.min(Math.floor(seekRange.end), value));
thumbnail =
await this.player.getThumbnails(thumbnailTrack.id, playerValue);
this.thumbnails_[value] = thumbnail;
theodab marked this conversation as resolved.
Show resolved Hide resolved
}
avelad marked this conversation as resolved.
Show resolved Hide resolved
if (!thumbnail || !thumbnail.uris.length) {
this.hideThumbnail_();
return;
}
const uri = thumbnail.uris[0].split('#xywh=')[0];
if (uri !== this.thumbnailImage_.src) {
try {
this.thumbnailContainer_.removeChild(this.thumbnailImage_);
} catch (e) {
// The image is not a child
}
this.thumbnailImage_ = /** @type {!HTMLImageElement} */ (
shaka.util.Dom.createHTMLElement('img'));
this.thumbnailImage_.id = 'shaka-player-ui-thumbnail-image';
this.thumbnailImage_.draggable = false;
this.thumbnailImage_.src = uri;
this.thumbnailContainer_.insertBefore(this.thumbnailImage_,
this.thumbnailContainer_.firstChild);
}
const scale = width / thumbnail.width;
if (thumbnail.imageHeight) {
this.thumbnailImage_.height = thumbnail.imageHeight;
} else if (!thumbnail.sprite) {
this.thumbnailImage_.style.height = '100%';
this.thumbnailImage_.style.objectFit = 'contain';
}
if (thumbnail.imageWidth) {
this.thumbnailImage_.width = thumbnail.imageWidth;
} else if (!thumbnail.sprite) {
this.thumbnailImage_.style.width = '100%';
this.thumbnailImage_.style.objectFit = 'contain';
}
this.thumbnailImage_.style.left = '-' + scale * thumbnail.positionX + 'px';
this.thumbnailImage_.style.top = '-' + scale * thumbnail.positionY + 'px';
this.thumbnailImage_.style.transform = 'scale(' + scale + ')';
this.thumbnailImage_.style.transformOrigin = 'left top';
// Update container height and top
theodab marked this conversation as resolved.
Show resolved Hide resolved
height = Math.floor(width * thumbnail.height / thumbnail.width);
this.thumbnailContainer_.style.height = height + 'px';
this.thumbnailContainer_.style.top = -(height - offsetTop) + 'px';
}


/**
* @return {?shaka.extern.Track} The thumbnail track.
* @private
*/
getThumbnailTrack_() {
const imageTracks = this.player.getImageTracks();
if (!imageTracks.length) {
return null;
}
const mimeTypesPreference = [
'image/avif',
'image/webp',
'image/jpeg',
'image/png',
'image/svg+xml',
];
for (const mimeType of mimeTypesPreference) {
const estimatedBandwidth = this.player.getStats().estimatedBandwidth;
const bestOptions = imageTracks.filter((track) => {
return track.mimeType.toLowerCase() === mimeType &&
track.bandwidth < estimatedBandwidth * 0.01;
}).sort((a, b) => {
return a.bandwidth - b.bandwidth;
}).reverse();
avelad marked this conversation as resolved.
Show resolved Hide resolved
if (bestOptions && bestOptions.length) {
return bestOptions[0];
}
}
return imageTracks[0];
}


/**
* @private
*/
hideThumbnail_() {
this.thumbnailContainer_.style.visibility = 'hidden';
this.thumbnailTime_.textContent = '';
}


/**
* @param {number} totalSeconds
* @private
*/
timeFormater_(totalSeconds) {
avelad marked this conversation as resolved.
Show resolved Hide resolved
const secondsNumber = Math.round(totalSeconds);
const hours = Math.floor(secondsNumber / 3600);
theodab marked this conversation as resolved.
Show resolved Hide resolved
let minutes = Math.floor((secondsNumber - (hours * 3600)) / 60);
let seconds = secondsNumber - (hours * 3600) - (minutes * 60);
if (seconds < 10) {
seconds = '0' + seconds;
}
if (hours > 0) {
if (minutes < 10) {
minutes = '0' + minutes;
}
return hours + ':' + minutes + ':' + seconds;
} else {
return minutes + ':' + seconds;
}
}
};


Expand Down