UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

360 lines (295 loc) • 9.8 kB
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(); } }