bitmovin-player-ui
Version:
Bitmovin Player UI Framework
412 lines (336 loc) • 14.3 kB
text/typescript
import { PlayerAPI, TimeRange } from 'bitmovin-player';
import { UIInstanceManager } from '../UIManager';
import { DOM } from '../DOM';
import { ComponentConfig } from '../components/Component';
import { TimelineMarker } from '../UIConfig';
import { SeekBar, SeekBarMarker, SeekPreviewEventArgs } from '../components/seekbar/SeekBar';
import { PlayerUtils } from './PlayerUtils';
import { Timeout } from './Timeout';
import { prefixCss } from '../components/DummyComponent';
const defaultMarkerUpdateIntervalMs = 1000;
/**
* @category Configs
*/
export interface MarkersConfig extends ComponentConfig {
/**
* Used for seekBar marker snapping range percentage
*/
snappingRange?: number;
/**
* The interval in milliseconds in which marker positions will be updated for live streams.
* Default: 1000
*/
markerUpdateIntervalMs?: number;
}
export class TimelineMarkersHandler {
private markersContainer: DOM;
private timelineMarkers: SeekBarMarker[];
private player: PlayerAPI;
private uimanager: UIInstanceManager;
private markerPositionUpdater: Timeout | null = null;
private getSeekBarWidth: () => number;
protected config: MarkersConfig;
private isTimeShifting: boolean = false;
// On some platforms, there are inconsistencies between the timeShift and currentTime values during time-shifting
// in a live stream. Those values are used to calculate a seekable-range during a live stream.
// To properly calculate the marker position, we rely on the seekable range of the DVR window.
// To work around the mentioned inconsistencies, we store the last known seekableRange and use
// it for marker position calculation during time-shifting/scrubbing.
private seekableRangeSnapshot: { start: number; end: number; timestampMs: number } | null = null;
constructor(config: MarkersConfig, getSeekBarWidth: () => number, markersContainer: DOM) {
this.config = config;
this.getSeekBarWidth = getSeekBarWidth;
this.markersContainer = markersContainer;
this.timelineMarkers = [];
}
public initialize(player: PlayerAPI, uimanager: UIInstanceManager) {
this.player = player;
this.uimanager = uimanager;
this.configureMarkers();
}
private configureMarkers(): void {
const onTimeShift = () => {
this.isTimeShifting = true;
};
const onTimeShifted = () => {
this.isTimeShifting = false;
};
const onSeekPreview = (_: SeekBar, args: SeekPreviewEventArgs) => {
if (args.scrubbing) {
onTimeShift();
}
};
const reset = () => {
this.stopLiveMarkerUpdater();
this.clearMarkers();
this.isTimeShifting = false;
this.seekableRangeSnapshot = null;
this.player.off(this.player.exports.PlayerEvent.TimeShift, onTimeShift);
this.player.off(this.player.exports.PlayerEvent.TimeShifted, onTimeShifted);
this.uimanager.onSeekPreview.unsubscribe(onSeekPreview);
};
this.player.on(this.player.exports.PlayerEvent.SourceUnloaded, reset);
this.player.on(this.player.exports.PlayerEvent.Destroy, reset);
this.player.on(this.player.exports.PlayerEvent.AdBreakStarted, () => this.clearMarkers());
this.player.on(this.player.exports.PlayerEvent.AdBreakFinished, () => this.updateMarkers());
const liveStreamDetector = new PlayerUtils.LiveStreamDetector(this.player, this.uimanager);
liveStreamDetector.onLiveChanged.subscribe((sender, args: PlayerUtils.LiveStreamDetectorEventArgs) => {
if (args.live) {
this.player.on(this.player.exports.PlayerEvent.TimeShift, onTimeShift);
this.player.on(this.player.exports.PlayerEvent.TimeShifted, onTimeShifted);
this.uimanager.onSeekPreview.subscribe(onSeekPreview);
this.startLiveMarkerUpdater();
} else {
this.stopLiveMarkerUpdater();
this.uimanager.onSeekPreview.unsubscribe(onSeekPreview);
this.player.off(this.player.exports.PlayerEvent.TimeShift, onTimeShift);
this.player.off(this.player.exports.PlayerEvent.TimeShifted, onTimeShifted);
}
});
liveStreamDetector.detect(); // Initial detection
this.uimanager.getConfig().events.onUpdated.subscribe(() => this.updateMarkers());
this.uimanager.onRelease.subscribe(() =>
this.uimanager.getConfig().events.onUpdated.unsubscribe(() => this.updateMarkers()),
);
// Refresh timeline markers when the player is resized or the UI is configured. Timeline markers
// are positioned absolutely and must therefore be updated when the size of the seekbar changes.
this.player.on(this.player.exports.PlayerEvent.PlayerResized, () => this.updateMarkersDOM());
// Additionally, when this code is called, the seekbar is not part of the UI yet and therefore does not have a size,
// resulting in a wrong initial position of the marker. Refreshing it once the UI is configured solved this issue.
this.uimanager.onConfigured.subscribe(() => {
this.updateMarkers();
});
this.player.on(this.player.exports.PlayerEvent.SourceLoaded, () => {
this.updateMarkers();
});
// Init markers at startup
this.updateMarkers();
}
public getMarkerAtPosition(percentage: number): SeekBarMarker | null {
const snappingRange = this.config.snappingRange;
const matchingMarker = this.timelineMarkers.find(marker => {
const hasDuration = marker.duration > 0;
// Handle interval markers
const intervalMarkerMatch =
hasDuration &&
percentage >= marker.position - snappingRange &&
percentage <= marker.position + marker.duration + snappingRange;
// Handle position markers
const positionMarkerMatch =
percentage >= marker.position - snappingRange && percentage <= marker.position + snappingRange;
return intervalMarkerMatch || positionMarkerMatch;
});
return matchingMarker || null;
}
private clearMarkers(): void {
this.timelineMarkers = [];
this.markersContainer.empty();
}
private removeMarkerFromConfig(marker: TimelineMarker): void {
this.uimanager.getConfig().metadata.markers = this.uimanager
.getConfig()
.metadata.markers.filter(_marker => marker !== _marker);
}
private filterRemovedMarkers(): void {
this.timelineMarkers = this.timelineMarkers.filter(seekbarMarker => {
const matchingMarker = this.uimanager
.getConfig()
.metadata.markers.find(_marker => seekbarMarker.marker === _marker);
if (!matchingMarker) {
this.removeMarkerFromDOM(seekbarMarker);
}
return matchingMarker;
});
}
private removeMarkerFromDOM(marker: SeekBarMarker): void {
if (marker.element) {
marker.element.remove();
}
}
private updateMarkers(): void {
const seekBarWidth = this.getSeekBarWidth();
if (seekBarWidth === 0) {
// Skip marker update when the seekBarWidth is not yet available.
// Will be updated by PlayerResized/onConfigured events once dimensions are available.
return;
}
if (!shouldProcessMarkers(this.player, this.uimanager)) {
this.clearMarkers();
return;
}
this.filterRemovedMarkers();
this.uimanager.getConfig().metadata.markers.forEach(marker => {
const { markerPosition, markerDuration } = getMarkerPositions(
this.player,
this.getSeekableRangeRespectingSnapshot(),
marker,
);
if (shouldRemoveMarker(markerPosition, markerDuration)) {
this.removeMarkerFromConfig(marker);
} else if (markerPosition <= 100) {
const matchingMarker = this.timelineMarkers.find(seekbarMarker => seekbarMarker.marker === marker);
if (matchingMarker) {
matchingMarker.position = markerPosition;
matchingMarker.duration = markerDuration;
this.updateMarkerDOM(matchingMarker);
} else {
const newMarker: SeekBarMarker = { marker, position: markerPosition, duration: markerDuration };
this.timelineMarkers.push(newMarker);
this.createMarkerDOM(newMarker);
}
}
});
}
private getMarkerCssProperties(
marker: SeekBarMarker,
includeTransition: boolean = true,
): { [propertyName: string]: string } {
const seekBarWidthPx = this.getSeekBarWidth();
const positionInPx = (seekBarWidthPx / 100) * (marker.position < 0 ? 0 : marker.position);
const cssProperties: { [propertyName: string]: string } = {
transform: `translateX(${positionInPx}px)`,
};
if (includeTransition) {
const updateIntervalMs = this.config.markerUpdateIntervalMs || defaultMarkerUpdateIntervalMs;
cssProperties['transition-duration'] = `${updateIntervalMs}ms`;
} else {
cssProperties['transition'] = 'none';
}
if (marker.duration > 0) {
const markerWidthPx = Math.round((seekBarWidthPx / 100) * marker.duration);
cssProperties['width'] = `${markerWidthPx}px`;
}
return cssProperties;
}
private updateMarkerDOM(marker: SeekBarMarker): void {
// Removing the 'transition: none' value from the initial creation when updating the marker position.
marker.element.removeCss('transition');
marker.element.css(this.getMarkerCssProperties(marker, true));
}
private createMarkerDOM(marker: SeekBarMarker): void {
const markerClasses = ['seekbar-marker']
.concat(marker.marker.cssClasses || [])
.map(cssClass => prefixCss(cssClass));
const markerStartIndicator = new DOM('div', {
class: prefixCss('seekbar-marker-indicator'),
});
const markerEndIndicator = new DOM('div', {
class: prefixCss('seekbar-marker-indicator'),
});
const markerElement = new DOM('div', {
class: markerClasses.join(' '),
'data-marker-time': String(marker.marker.time),
'data-marker-title': String(marker.marker.title),
})
// We do not want to animate the initial creation of a marker to prevent a 'fly in' animation.
// Only updating the marker position will be animated.
.css(this.getMarkerCssProperties(marker, false));
if (marker.marker.imageUrl) {
const removeImage = () => {
imageElement.remove();
};
const imageElement = new DOM('img', {
class: prefixCss('seekbar-marker-image'),
src: marker.marker.imageUrl,
}).on('error', removeImage);
markerElement.append(imageElement);
}
markerElement.append(markerStartIndicator);
if (marker.duration > 0) {
markerElement.append(markerEndIndicator);
}
marker.element = markerElement;
this.markersContainer.append(markerElement);
}
private updateMarkersDOM(): void {
this.timelineMarkers.forEach(marker => {
if (marker.element) {
this.updateMarkerDOM(marker);
} else {
this.createMarkerDOM(marker);
}
});
}
private startLiveMarkerUpdater(): void {
const updateIntervalMs = this.config.markerUpdateIntervalMs || defaultMarkerUpdateIntervalMs;
this.stopLiveMarkerUpdater();
this.captureSeekableRangeSnapshot();
this.markerPositionUpdater = new Timeout(
updateIntervalMs,
() => {
if (!this.isTimeShifting) {
this.captureSeekableRangeSnapshot();
}
this.updateMarkers();
},
true,
);
this.markerPositionUpdater.start();
}
private stopLiveMarkerUpdater(): void {
if (this.markerPositionUpdater) {
this.markerPositionUpdater.clear();
this.markerPositionUpdater = null;
}
}
private captureSeekableRangeSnapshot(): void {
const seekableRange = PlayerUtils.getSeekableRangeRespectingLive(this.player);
this.seekableRangeSnapshot = {
start: seekableRange.start,
end: seekableRange.end,
timestampMs: Date.now(),
};
}
private getSeekableRangeRespectingSnapshot(): TimeRange {
const seekableRange = PlayerUtils.getSeekableRangeRespectingLive(this.player);
if (!this.player.isLive()) {
return seekableRange;
}
if (this.isTimeShifting && this.seekableRangeSnapshot) {
// Interpolate the last snapshot so the sliding DVR window keeps moving while time-shifting.
const elapsedSeconds = (Date.now() - this.seekableRangeSnapshot.timestampMs) / 1000;
return {
start: this.seekableRangeSnapshot.start + elapsedSeconds,
end: this.seekableRangeSnapshot.end + elapsedSeconds,
};
}
return seekableRange;
}
}
function getMarkerPositions(player: PlayerAPI, seekableRange: TimeRange, marker: TimelineMarker) {
const duration = getDuration(player, seekableRange);
const markerPosition = (100 / duration) * getMarkerTime(marker, player, duration, seekableRange); // convert absolute time to percentage
let markerDuration = (100 / duration) * marker.duration;
if (markerPosition < 0 && !isNaN(markerDuration)) {
// Shrink marker duration for on live streams as they reach end
markerDuration = markerDuration + markerPosition;
}
if (100 - markerPosition < markerDuration) {
// Shrink marker if it overflows timeline
markerDuration = 100 - markerPosition;
}
return { markerDuration, markerPosition };
}
function getMarkerTime(marker: TimelineMarker, player: PlayerAPI, duration: number, seekableRange: TimeRange): number {
if (!player.isLive()) {
return marker.time;
}
return duration - (seekableRange.end - marker.time);
}
function getDuration(player: PlayerAPI, seekableRange: TimeRange): number {
if (!player.isLive()) {
return player.getDuration();
}
const { start, end } = seekableRange;
return end - start;
}
function shouldRemoveMarker(markerPosition: number, markerDuration: number): boolean {
return (markerDuration < 0 || isNaN(markerDuration)) && markerPosition < 0;
}
function shouldProcessMarkers(player: PlayerAPI, uimanager: UIInstanceManager): boolean {
// Don't generate timeline markers if we don't yet have a duration
// The duration check is for buggy platforms where the duration is not available instantly (Chrome on Android 4.3)
const validToProcess = player.getDuration() !== Infinity || player.isLive();
const hasMarkers = uimanager.getConfig().metadata.markers.length > 0;
return validToProcess && hasMarkers;
}