@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();
  }
}