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

Optimize auto-advance logic in TimeRange #70

Merged
merged 9 commits into from
Aug 29, 2024
Merged
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