@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
360 lines (295 loc) • 9.8 kB
text/typescript
import uuidv4 from "uuid/v4";
import { AccessibilityManager } from "@applicaster/zapp-react-native-utils/appUtils/accessibilityManager";
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
import { Player } from "@applicaster/zapp-react-native-utils/appUtils/playerManager/player";
const { log_debug, log_error } = createLogger({
subsystem: "Player",
category: "PlayerTTS",
});
enum SEEK_DIRECTION {
FORWARD = "forward",
REWIND = "back",
}
const hasPrerollAds = (entry: ZappEntry): boolean => {
const videoAds = entry?.extensions?.video_ads;
if (!videoAds) {
return false;
}
// If it's a string (VMAP URL), assume it might have preroll
if (typeof videoAds === "string") {
return true;
}
// If it's an array, check for preroll offset
if (Array.isArray(videoAds)) {
return videoAds.some(
(ad: ZappVideoAdExtension) => ad.offset === "preroll" || ad.offset === 0
);
}
return false;
};
export class PlayerTTS {
private player: Player;
private accessibilityManager: AccessibilityManager;
private seekStartPosition: number | null = null;
private isSeeking: boolean = false;
private listenerId: string;
private isInitialPlayerOpen: boolean = true;
private isPrerollActive: boolean = false;
private hasPrerollAds: boolean = false; // Track if preroll ads are expected
constructor(player: Player, accessibilityManager: AccessibilityManager) {
this.player = player;
this.accessibilityManager = accessibilityManager;
this.listenerId = `player-tts-${uuidv4()}`;
this.hasPrerollAds = hasPrerollAds(player.entry);
log_debug("PlayerTTS initialized", {
hasPrerollAds: this.hasPrerollAds,
listenerId: this.listenerId,
entryTitle: player.entry.title,
});
}
private numberToWords(num: number): string {
const ones = [
"",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
];
const tens = [
"",
"",
"twenty",
"thirty",
"forty",
"fifty",
"sixty",
"seventy",
"eighty",
"ninety",
];
if (num === 0) return "zero";
if (num < 20) return ones[num];
const ten = Math.floor(num / 10);
const one = num % 10;
return one === 0 ? tens[ten] : `${tens[ten]} ${ones[one]}`;
}
private secondsToTime(
seconds: number,
format: "natural" | "standard" = "natural"
): string {
if (seconds < 0) return format === "natural" ? "zero" : "0";
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (format === "standard") {
const parts = [];
if (minutes > 0) {
parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
}
if (remainingSeconds > 0) {
parts.push(
`${remainingSeconds} second${remainingSeconds !== 1 ? "s" : ""}`
);
}
return parts.length > 0 ? parts.join(" ") : "0";
} else {
if (minutes === 0) {
if (remainingSeconds === 0) return "zero";
if (remainingSeconds < 10) {
return `zero o ${this.numberToWords(remainingSeconds)}`;
}
return `zero ${this.numberToWords(remainingSeconds)}`;
}
if (remainingSeconds === 0) {
return `${this.numberToWords(minutes)}`;
}
if (remainingSeconds < 10) {
return `${this.numberToWords(minutes)} o ${this.numberToWords(remainingSeconds)}`;
}
return `${this.numberToWords(minutes)} ${this.numberToWords(remainingSeconds)}`;
}
}
private announcePause = () => {
if (!this.isSeeking) {
this.accessibilityManager.addHeading(
`Paused - ${this.secondsToTime(this.player.playerState.contentPosition, "standard")}`
);
}
};
private announceContentStart(
options: {
currentTime?: number;
duration?: number;
useReadText?: boolean;
} = {}
): void {
const { currentTime, duration, useReadText = false } = options;
const state = this.player.playerState;
const timeRemaining =
(duration || state?.contentDuration || 0) -
(currentTime || state?.contentPosition || 0);
const title = (this.player.entry.title as string) || "";
const summary = (this.player.entry.summary as string) || "";
log_debug("Announcing content start", {
title,
currentTime: currentTime || state?.contentPosition || 0,
duration: duration || state?.contentDuration || 0,
timeRemaining,
useReadText,
});
this.accessibilityManager.addHeading(`Playing - ${title}`);
if (summary) this.accessibilityManager.addHeading(summary);
this.accessibilityManager.addHeading(
`Playing from ${this.secondsToTime(currentTime || state?.contentPosition || 0, "standard")}`
);
const remainingText = `${this.secondsToTime(Math.max(0, Math.floor(timeRemaining)), "standard")} remaining.`;
if (useReadText) {
this.accessibilityManager.readText({ text: remainingText });
} else {
this.accessibilityManager.addHeading(remainingText);
}
this.accessibilityManager.setInitialPlayerAnnouncementReady();
this.isInitialPlayerOpen = false;
}
private announceBufferComplete = (event: any) => {
// If preroll ads are expected, wait for them to finish before announcing content
if (this.hasPrerollAds && this.isInitialPlayerOpen) {
log_debug("Waiting for preroll ads to finish", {
hasPrerollAds: this.hasPrerollAds,
isInitialPlayerOpen: this.isInitialPlayerOpen,
});
return;
}
// Gate content announcement until preroll finishes
if (this.isInitialPlayerOpen && !this.isPrerollActive) {
log_debug("Buffer complete - announcing content", {
currentTime: event.currentTime,
duration: event.duration,
isPrerollActive: this.isPrerollActive,
});
this.announceContentStart({
currentTime: event.currentTime,
duration: event.duration,
});
}
};
private announceResume = () => {
if (!this.isSeeking && !this.isInitialPlayerOpen) {
log_debug("Player resumed", {
contentPosition: this.player.playerState.contentPosition,
isSeeking: this.isSeeking,
isInitialPlayerOpen: this.isInitialPlayerOpen,
});
this.accessibilityManager.addHeading(
`Playing - ${this.secondsToTime(this.player.playerState.contentPosition, "standard")}`
);
}
};
private handleVideoProgress = (event: any) => {
if (event.currentTime > 0) {
this.seekStartPosition = event.currentTime;
}
};
private handleSeekComplete = (event: any) => {
if (this.seekStartPosition !== null) {
const seekDirection =
event.currentTime > this.seekStartPosition
? SEEK_DIRECTION.FORWARD
: SEEK_DIRECTION.REWIND;
const seekAmount = Math.round(
Math.abs(event.currentTime - this.seekStartPosition)
);
log_debug("Seek completed", {
seekDirection,
seekAmount,
fromPosition: this.seekStartPosition,
toPosition: event.currentTime,
});
this.accessibilityManager.readText({
text: `Skipped ${seekDirection} ${this.secondsToTime(seekAmount, "standard")}`,
});
this.seekStartPosition = event.currentTime;
}
this.isSeeking = false;
};
private handleSeekStart = () => {
log_debug("Seek started");
this.isSeeking = true;
};
private handlePlayerClose = () => {
log_debug("Player closed - resetting state");
this.isInitialPlayerOpen = true;
this.accessibilityManager.resetInitialPlayerAnnouncementReady();
};
private announceAdBegin = (event: any) => {
this.isPrerollActive = true;
log_debug("Ad started", {
adDuration: event?.ad?.data?.duration,
isPrerollActive: this.isPrerollActive,
});
if (event?.ad?.data?.duration) {
this.accessibilityManager.readText({
text: `Sponsored. Ends in ${this.secondsToTime(event.ad.data.duration, "standard")}`,
});
}
};
private handleAdEnd = (_event: any) => {
this.isPrerollActive = false;
log_debug("Ad ended", {
isPrerollActive: this.isPrerollActive,
isInitialPlayerOpen: this.isInitialPlayerOpen,
});
// If initial entry still pending, trigger content announcement using latest player state
if (this.isInitialPlayerOpen) {
this.announceContentStart({ useReadText: true });
}
};
public init(): () => void {
if (!this.player) {
log_error("Failed to initialize PlayerTTS - no player provided");
return () => {};
}
log_debug("Initializing PlayerTTS listeners", {
listenerId: this.listenerId,
});
return this.player.addListener({
id: this.listenerId,
listener: {
onBufferComplete: this.announceBufferComplete,
onPlayerResume: this.announceResume,
onPlayerPause: this.announcePause,
onVideoProgress: this.handleVideoProgress,
onPlayerSeekStart: this.handleSeekStart,
onPlayerSeekComplete: this.handleSeekComplete,
onPlayerClose: this.handlePlayerClose,
onAdBegin: this.announceAdBegin,
onAdEnd: this.handleAdEnd,
onAdBreakEnd: this.handleAdEnd,
},
});
}
public destroy(): void {
log_debug("Destroying PlayerTTS", {
listenerId: this.listenerId,
});
if (this.player) {
this.player.removeListener(this.listenerId);
}
this.seekStartPosition = null;
this.handlePlayerClose();
}
}