Skip to content

Commit

Permalink
Merge pull request #70 from THEOplayer/optimize-time-range
Browse files Browse the repository at this point in the history
Optimize auto-advance logic in `TimeRange`
  • Loading branch information
MattiasBuelens authored Aug 29, 2024
2 parents d87933f + f2ffc52 commit c59f859
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 30 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ sidebar_custom_props: { 'icon': '📰' }
## Unreleased

- 🐛 Fixed blank space below UI when using `<theoplayer-default-ui>`.
- 💅 Optimized performance of `<theoplayer-time-range>`. ([#70](https://github.com/THEOplayer/web-ui/issues/70))
- Optimized the `requestAnimationFrame` callback used to update the seekbar's progress
to avoid synchronous re-layouts as much as possible.
- When playing a long video, the seek bar no longer uses `requestAnimationFrame` at all to update its progress.
Instead, it updates using only less frequent `timeupdate` events.

## v1.8.1 (2024-04-18)

Expand Down
49 changes: 35 additions & 14 deletions src/components/Range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export abstract class Range extends StateReceiverMixin(HTMLElement, ['deviceType

protected readonly _rangeEl: HTMLInputElement;
protected readonly _pointerEl: HTMLElement;
private _lastRangeWidth: number = 0;
private _rangeWidth: number = 0;
private _thumbWidth: number = 10;

constructor(options: RangeOptions) {
super();
Expand Down Expand Up @@ -171,10 +172,13 @@ export abstract class Range extends StateReceiverMixin(HTMLElement, ['deviceType
this.update();
}

protected update(): void {
protected update(useCachedWidth?: boolean): void {
if (this.hasAttribute(Attribute.HIDDEN)) {
return;
}
if (!useCachedWidth) {
this.updateCachedWidths_();
}
this._rangeEl.setAttribute('aria-valuetext', this.getAriaValueText());
this.updateBar_();
}
Expand Down Expand Up @@ -207,34 +211,51 @@ export abstract class Range extends StateReceiverMixin(HTMLElement, ['deviceType
* Creating an array so progress-bar can insert the buffered bar.
*/
protected getBarColors(): ColorStops {
const relativeValue = this.value - this.min;
const relativeMax = this.max - this.min;
const { value, min, max } = this;
const relativeValue = value - min;
const relativeMax = max - min;
let rangePercent = (relativeValue / relativeMax) * 100;
if (isNaN(rangePercent)) {
rangePercent = 0;
}

// Use the last non-zero range width, in case the range is temporarily hidden.
const rangeWidth = this._rangeEl.offsetWidth;
if (rangeWidth > 0) {
this._lastRangeWidth = rangeWidth;
}

let thumbPercent = 0;
// If the range thumb is at min or max don't correct the time range.
// Ideally the thumb center would go all the way to min and max values
// but input[type=range] doesn't play like that.
if (this.min < this.value && this.value < this.max) {
const thumbWidth = getComputedStyle(this).getPropertyValue('--theoplayer-range-thumb-width') || '10px';
const thumbOffset = parseInt(thumbWidth) * (0.5 - rangePercent / 100);
thumbPercent = (thumbOffset / this._lastRangeWidth) * 100;
if (min < value && value < max) {
const thumbOffset = this._thumbWidth * (0.5 - rangePercent / 100);
thumbPercent = (thumbOffset / this._rangeWidth) * 100;
}

const stops = new ColorStops();
stops.add('var(--theoplayer-range-bar-color, #fff)', 0, rangePercent + thumbPercent);
return stops;
}

private updateCachedWidths_(): void {
// Use the last non-zero range width, in case the range is temporarily hidden.
const rangeWidth = this._rangeEl.offsetWidth;
if (rangeWidth > 0) {
this._rangeWidth = rangeWidth;
}
const thumbWidth = parseInt(getComputedStyle(this).getPropertyValue('--theoplayer-range-thumb-width') || '10px');
if (thumbWidth > 0) {
this._thumbWidth = thumbWidth;
}
}

protected getMinimumStepForVisibleChange_(): number {
// The smallest visible change is 1 pixel.
// Compute how much the value needs to change for that.
const { min, max } = this;
const relativeMax = max - min;
if (relativeMax <= 0) {
return NaN;
}
return relativeMax / this._rangeWidth;
}

private readonly _updatePointerBar = (e: PointerEvent): void => {
if (this.disabled) {
return;
Expand Down
51 changes: 35 additions & 16 deletions src/components/TimeRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Range, rangeTemplate } from './Range';
import timeRangeHtml from './TimeRange.html';
import timeRangeCss from './TimeRange.css';
import { StateReceiverMixin } from './StateReceiverMixin';
import type { Ads, ChromelessPlayer } from 'theoplayer/chromeless';
import type { Ads, ChromelessPlayer, TimeRanges } from 'theoplayer/chromeless';
import { formatAsTimePhrase } from '../util/TimeUtils';
import { createCustomEvent } from '../util/EventUtils';
import type { PreviewTimeChangeEvent } from '../events/PreviewTimeChangeEvent';
Expand All @@ -22,7 +22,7 @@ import './PreviewTimeDisplay';
const template = createTemplate('theoplayer-time-range', rangeTemplate(timeRangeHtml, timeRangeCss));

const UPDATE_EVENTS = ['timeupdate', 'durationchange', 'ratechange', 'seeking', 'seeked'] as const;
const AUTO_ADVANCE_EVENTS = ['play', 'pause', 'ended', 'readystatechange', 'error'] as const;
const AUTO_ADVANCE_EVENTS = ['play', 'pause', 'ended', 'durationchange', 'readystatechange', 'error'] as const;
const AD_EVENTS = ['adbreakbegin', 'adbreakend', 'adbreakchange', 'updateadbreak', 'adbegin', 'adend', 'adskip', 'addad', 'updatead'] as const;
const DEFAULT_MISSING_TIME_PHRASE = 'video not loaded, unknown time';

Expand Down Expand Up @@ -120,31 +120,35 @@ export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType'
this._lastCurrentTime = this._player.currentTime;
this._lastPlaybackRate = this._player.playbackRate;
const seekable = this._player.seekable;
let min: number;
let max: number;
if (seekable.length !== 0) {
this.min = seekable.start(0);
this.max = seekable.end(0);
min = seekable.start(0);
max = seekable.end(0);
} else {
this.min = 0;
this.max = this._player.duration;
min = 0;
max = this._player.duration;
}
if (!isFinite(this._lastCurrentTime)) {
const isLive = this._player.duration === Infinity;
this._lastCurrentTime = isLive ? this.max : this.min;
this._lastCurrentTime = isLive ? max : min;
}
this._rangeEl.min = String(min);
this._rangeEl.max = String(max);
this._rangeEl.valueAsNumber = this._lastCurrentTime;
this.update();
this._updateDisabled();
this.updateDisabled_(seekable);
};

private readonly _updateDisabled = () => {
private updateDisabled_(seekable: TimeRanges | undefined = this._player?.seekable) {
let disabled = this.streamType === 'live';
if (this._player !== undefined) {
disabled ||= this._player.seekable.length === 0;
if (seekable !== undefined) {
disabled ||= seekable.length === 0;
}
if (this.disabled !== disabled) {
this.disabled = disabled;
}
};
}

protected override getAriaLabel(): string {
return 'seek';
Expand All @@ -165,7 +169,7 @@ export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType'
return;
}
if (attrName === Attribute.STREAM_TYPE) {
this._updateDisabled();
this.updateDisabled_();
} else if (attrName === Attribute.SHOW_AD_MARKERS) {
this.update();
}
Expand Down Expand Up @@ -215,10 +219,20 @@ export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType'
!this._player.paused &&
!this._player.ended &&
!this._player.errorObject &&
this._player.readyState >= 3
this._player.readyState >= 3 &&
this.needToUpdateEveryFrame_()
);
}

private needToUpdateEveryFrame_(): boolean {
// The player fires at least one timeupdate event every 250ms.
// If it takes more than 250ms to advance the playhead by 1 pixel,
// then we definitely don't need to update every frame.
const minimumStep = this.getMinimumStepForVisibleChange_();
const timeUpdateStep = 0.25 * this._lastPlaybackRate;
return minimumStep < timeUpdateStep;
}

private readonly _toggleAutoAdvance = () => {
if (this.shouldAutoAdvance_()) {
if (this._autoAdvanceId === 0) {
Expand All @@ -241,8 +255,13 @@ export class TimeRange extends StateReceiverMixin(Range, ['player', 'streamType'
}

const delta = (performance.now() - this._lastUpdateTime) / 1000;
this._rangeEl.valueAsNumber = this._lastCurrentTime + delta * this._lastPlaybackRate;
this.update();
const newValue = this._lastCurrentTime + delta * this._lastPlaybackRate;
if (Math.abs(newValue - this.value) >= this.getMinimumStepForVisibleChange_()) {
this._rangeEl.valueAsNumber = newValue;

// Use cached width to avoid synchronous layout
this.update(/* useCachedWidth = */ true);
}

this._autoAdvanceId = requestAnimationFrame(this._autoAdvanceWhilePlaying);
};
Expand Down

0 comments on commit c59f859

Please sign in to comment.