bitmovin-player-ui
Version:
Bitmovin Player UI Framework
185 lines (154 loc) • 6.93 kB
text/typescript
import { AdBreak, AdBreakEvent, PlayerAPI } from 'bitmovin-player';
import { Event, EventDispatcher } from '../EventDispatcher';
export interface AdBreakTrackerAdCountChangedArgs {
currentAdIndex: number;
totalNumberOfAds: number;
}
/**
* Tracks subsequent ad breaks that share the same `scheduleTime`, enabling a unified ad counter
* across what the player models as separate ad breaks.
*
* When multiple ad breaks are scheduled at the same position, the player fires separate
* `AdBreakStarted`/`AdBreakFinished` events for each. This tracker retains the ad break objects
* that the player removes from `player.ads.list()` after they finish, so that
* {@link currentAdIndex} and {@link totalNumberOfAds} can be derived lazily from the retained
* breaks plus the player's current state.
*
* @category Utils
*/
export class AdBreakTracker {
// Ad breaks belonging to the current group, captured as each break starts.
// The player removes finished breaks from `list()`, so we retain them here.
private groupBreaks: AdBreak[] = [];
// scheduleTime shared by the current group, or undefined when not in a group.
private groupScheduleTime: number | undefined = undefined;
private readonly events = {
onAdCountChanged: new EventDispatcher<AdBreakTracker, AdBreakTrackerAdCountChangedArgs>(),
};
constructor(private readonly player: PlayerAPI) {
// Subsequent ad break detection is done in `AdStarted` because the ad UI variant is not yet configured when
// the `AdBreakStarted` event fires
player.on(player.exports.PlayerEvent.AdStarted, this.handleAdStarted);
player.on(player.exports.PlayerEvent.AdBreakFinished, this.handleAdBreakFinished);
}
get onAdCountChanged(): Event<AdBreakTracker, AdBreakTrackerAdCountChangedArgs> {
return this.events.onAdCountChanged.getEvent();
}
/**
* Index of the currently playing ad across all subsequent ad breaks (1-based), or 0 when no ad
* is active.
*/
get currentAdIndex(): number {
const activeAd = this.player.ads?.getActiveAd?.();
if (!activeAd || this.groupBreaks.length === 0) {
return 0;
}
let offset = 0;
for (const adBreak of this.groupBreaks) {
const ads = adBreak.ads ?? [];
if (ads.length > 0) {
// ad.id/activeAd.id may be null/undefined, in which case we fall back to object reference comparison
const activeAdIndex = ads.findIndex(ad =>
activeAd.id != null && ad.id != null ? ad.id === activeAd.id : ad === activeAd,
);
if (activeAdIndex >= 0) {
return offset + activeAdIndex + 1;
}
offset += ads.length;
} else {
// ads not yet populated — if this is the active break, the active ad is its first ad
const activeBreak = this.player.ads?.getActiveAdBreak?.();
if (activeBreak === adBreak || (activeBreak?.id != null && activeBreak.id === adBreak.id)) {
return offset + 1;
}
offset += 1;
}
}
// Active ad not found in any retained break — fall back to offset + 1
return offset + 1;
}
/** Total ad count across all subsequent ad breaks. */
get totalNumberOfAds(): number {
if (this.groupBreaks.length === 0) {
return 0;
}
// Subsequent ad break ads arrays may not be populated yet (VAST manifests may load lazily), so we use the ads count
// if available, or assume 1 ad per break if not available. It will update and self-correct with each AdStarted event.
const retainedCount = this.groupBreaks.reduce(
(sum, adBreak) => sum + (adBreak.ads?.length > 0 ? adBreak.ads.length : 1),
0,
);
const remainingScheduledCount = (this.player.ads?.list?.() ?? [])
.filter(b => b.scheduleTime === this.groupScheduleTime)
.reduce((sum, adBreak) => sum + (adBreak.ads?.length > 0 ? adBreak.ads.length : 1), 0);
return retainedCount + remainingScheduledCount;
}
/** Unsubscribes all player events and resets state. Call when the tracker is no longer needed. */
release(): void {
this.player.off(this.player.exports.PlayerEvent.AdStarted, this.handleAdStarted);
this.player.off(this.player.exports.PlayerEvent.AdBreakFinished, this.handleAdBreakFinished);
this.reset();
this.events.onAdCountChanged.unsubscribeAll();
}
private readonly handleAdStarted = (): void => {
const activeBreak = this.player.ads?.getActiveAdBreak?.();
if (!activeBreak) {
this.reset();
this.dispatchChanged();
return;
}
const hasSubsequentBreaks = (this.player.ads?.list?.() ?? []).some(
b => b.scheduleTime === activeBreak.scheduleTime,
);
const isPartOfExistingGroup = this.groupBreaks.length > 0 && activeBreak.scheduleTime === this.groupScheduleTime;
if (isPartOfExistingGroup || hasSubsequentBreaks) {
if (!isPartOfExistingGroup && this.groupBreaks.length > 0) {
// New group at a different scheduleTime — clear stale state from a previous group
this.groupBreaks = [];
}
this.groupScheduleTime = activeBreak.scheduleTime;
// Add the active break if it's not already retained (new break in the group)
if (!this.groupBreaks.includes(activeBreak)) {
this.groupBreaks.push(activeBreak);
}
} else {
// Single ad break, not part of a group
this.groupBreaks = [activeBreak];
this.groupScheduleTime = undefined;
}
this.dispatchChanged();
};
private readonly handleAdBreakFinished = (adBreakFinishedEvent: AdBreakEvent): void => {
const adBreak = adBreakFinishedEvent.adBreak;
if (adBreak.scheduleTime !== this.groupScheduleTime) {
if (this.groupScheduleTime === undefined) {
this.reset();
this.dispatchChanged();
}
return;
}
const subsequentAdBreaks = (this.player.ads?.list?.() ?? []).filter(b => b.scheduleTime === this.groupScheduleTime);
// The next break in the group may already be active (and thus removed from `list()`),
// so also check whether the currently active break shares the same scheduleTime.
const activeBreak = this.player.ads?.getActiveAdBreak?.();
const activeBreakInGroup =
activeBreak?.scheduleTime === this.groupScheduleTime && this.groupScheduleTime !== undefined;
if (subsequentAdBreaks.length === 0 && !activeBreakInGroup) {
this.reset();
this.dispatchChanged();
}
// When more breaks remain in the group, skip dispatching — the next AdStarted will
// dispatch up-to-date values. Between breaks there is no active ad, so the lazy
// getters cannot produce meaningful values.
};
private dispatchChanged(): void {
this.events.onAdCountChanged.dispatch(this, {
currentAdIndex: this.currentAdIndex,
totalNumberOfAds: this.totalNumberOfAds,
});
}
private reset(): void {
this.groupBreaks = [];
this.groupScheduleTime = undefined;
}
}