import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useKeplerAppStateManager } from "@amazon-devices/react-native-kepler"; import { View, Text, StyleSheet, Image, useWindowDimensions } from "react-native"; import { logger, NamiEventEmitter, NamiPaywallAction, PAYWALL_ACTION_EVENT, } from "@namiml/sdk-core"; import { applyStyles, parseSizeOrPercent } from "../../utils/styles"; import { usePaywallContext } from "../../context/PaywallContext"; import { buildSmartTextReplacements, interpolateSmartText, } from "../../utils/smartText"; import { initializeVideoControls, setVideoMuted, setVideoPlaying, subscribeVideoControls, } from "../../utils/videoControls"; interface Props { component: any; scaleFactor: number; parentDirection?: string; } type W3CMediaModule = { VideoPlayer: new () => any; KeplerVideoSurfaceView: React.ComponentType; KeplerMediaControlHandler: new () => any; }; function serializePlaybackError(reason: unknown): Record { if (reason instanceof Error) { return { name: reason.name, message: reason.message, stack: reason.stack, }; } if (reason && typeof reason === "object") { const output: Record = {}; for (const [key, value] of Object.entries(reason)) { output[key] = value; } return output; } return { value: reason == null ? "" : String(reason), }; } export const NamiVideo: React.FC = ({ component, scaleFactor, parentDirection, }) => { const ctx = usePaywallContext(); const keplerAppStateManager = useKeplerAppStateManager(); const { width: deviceWidth, height: deviceHeight } = useWindowDimensions(); const [playing, setPlaying] = useState(!!component.autoplayVideo); const [muted, setMuted] = useState(!!(component.mute ?? component.muted)); const [playbackFailed, setPlaybackFailed] = useState(false); const [activeUrl, setActiveUrl] = useState(""); const [metadataReady, setMetadataReady] = useState(false); const [surfaceReady, setSurfaceReady] = useState(false); const surfaceHandleRef = useRef(null); const playerRef = useRef(null); const disposedRef = useRef(false); const smartTextSku = component?.smartTextSku ?? component?.sku; const mediaLib = useMemo(() => { try { return require("@amazon-devices/react-native-w3cmedia"); } catch { return null; } }, []); const containerStyle = applyStyles( component, scaleFactor, false, parentDirection, ); const width = parseSizeOrPercent(component.width ?? component.fixedWidth, scaleFactor) ?? "100%"; const height = parseSizeOrPercent( component.height ?? component.fixedHeight, scaleFactor, ) ?? 200; const resolvedWidth = typeof width === "string" && width.endsWith("%") ? (deviceWidth * Number.parseFloat(width)) / 100 : width; const resolvedHeight = typeof height === "string" && height.endsWith("%") ? (deviceHeight * Number.parseFloat(height)) / 100 : height; const poster = component.fallbackImage ?? ""; const scalingmode = component.imageCropping === "fit" ? "fit" : "fill"; const loop = !!(component.loopVideo ?? component.loop); const shouldAutoplay = component.paused != null ? !component.paused : !!component.autoplayVideo; const resolvedUrl = useMemo(() => { const raw = component.url ?? component.videoUrl ?? (ctx.state as any).appSuppliedVideoUrl ?? ""; const replacements = buildSmartTextReplacements( ctx.state, ctx.flow, smartTextSku, ); const resolved = interpolateSmartText(raw, replacements); return resolved == null ? "" : String(resolved); }, [component, ctx.state, ctx.flow, smartTextSku]); useEffect(() => { initializeVideoControls({ playing: component.paused != null ? !component.paused : !!component.autoplayVideo, muted: !!(component.mute ?? component.muted), }); }, [ component.paused, component.autoplayVideo, component.mute, component.muted, ]); useEffect(() => { return subscribeVideoControls((state) => { setPlaying(state.playing); setMuted(state.muted); }); }, []); useEffect(() => { setPlaybackFailed(false); setMetadataReady(false); setSurfaceReady(false); setActiveUrl(resolvedUrl); }, [resolvedUrl, component?.id]); const emitVideoEvent = useCallback( (action: NamiPaywallAction) => { const player = playerRef.current; NamiEventEmitter.getInstance().emit(PAYWALL_ACTION_EVENT, { ...ctx.getPaywallActionEventData(), action, videoMetaData: { id: component?.id, name: component?.title, url: activeUrl, loopVideo: loop, muteByDefault: !!(component.mute ?? component.muted), autoplayVideo: shouldAutoplay, contentDuration: typeof player?.duration === "number" ? player.duration : undefined, contentTimecode: typeof player?.currentTime === "number" ? player.currentTime : undefined, }, }); }, [ ctx, component?.id, component?.title, component?.mute, component?.muted, activeUrl, loop, shouldAutoplay, ], ); const handlePlaybackFailure = useCallback( (reason?: unknown) => { logger.warn( "[NamiVideo][vega] Video playback failed; falling back to poster image.", { id: component?.id, title: component?.title, url: activeUrl, error: serializePlaybackError(reason), metadataReady, surfaceReady, playing, muted, }, ); setPlaybackFailed(true); setMetadataReady(false); setVideoPlaying(false); emitVideoEvent(NamiPaywallAction.VIDEO_PAUSED); }, [ component?.id, component?.title, activeUrl, shouldAutoplay, emitVideoEvent, ], ); const attachSurfaceAndMaybePlay = useCallback(async () => { const player = playerRef.current; if (!player || !surfaceHandleRef.current) return; try { player.setSurfaceHandle?.(surfaceHandleRef.current); if (metadataReady && shouldAutoplay && playing && !playbackFailed) { await player.play?.(); } } catch (error) { handlePlaybackFailure(error); } }, [ handlePlaybackFailure, metadataReady, playbackFailed, playing, shouldAutoplay, ]); useEffect(() => { if (!mediaLib || !activeUrl) return; const { VideoPlayer, KeplerMediaControlHandler } = mediaLib; disposedRef.current = false; let localPlayer: any = null; const listeners: Array<[string, (event?: any) => void]> = []; const addListener = (type: string, handler: (event?: any) => void) => { localPlayer?.addEventListener?.(type, handler); listeners.push([type, handler]); }; const cleanup = async () => { try { localPlayer?.pause?.(); } catch { // no-op } for (const [type, handler] of listeners) { try { localPlayer?.removeEventListener?.(type, handler); } catch { // no-op } } try { if (surfaceHandleRef.current) { localPlayer?.clearSurfaceHandle?.(surfaceHandleRef.current); } } catch { // no-op } try { if (typeof localPlayer?.deinitializeSync === "function") { localPlayer.deinitializeSync(1500); } else { await localPlayer?.deinitialize?.(); } } catch { // no-op } surfaceHandleRef.current = null; if (playerRef.current === localPlayer) { playerRef.current = null; } }; const initializePlayer = async () => { try { localPlayer = new VideoPlayer(); playerRef.current = localPlayer; try { const componentInstance = keplerAppStateManager?.getComponentInstance?.(); if (componentInstance) { await localPlayer.setMediaControlFocus?.( componentInstance, new KeplerMediaControlHandler(), ); } } catch { // no-op } await localPlayer.initialize?.(); if (disposedRef.current) { await cleanup(); return; } addListener("loadedmetadata", async () => { setMetadataReady(true); await attachSurfaceAndMaybePlay(); }); addListener("waiting", () => { logger.debug?.("[NamiVideo][vega] VideoPlayer waiting for data.", { id: component?.id, title: component?.title, url: activeUrl, }); }); addListener("playing", () => { setPlaybackFailed(false); setVideoPlaying(true); const currentTime = Number(localPlayer?.currentTime ?? 0); emitVideoEvent( currentTime > 0.05 ? NamiPaywallAction.VIDEO_RESUMED : NamiPaywallAction.VIDEO_STARTED, ); }); addListener("pause", () => { setVideoPlaying(false); if (!playbackFailed) { emitVideoEvent(NamiPaywallAction.VIDEO_PAUSED); } }); addListener("ended", () => { setVideoPlaying(false); emitVideoEvent(NamiPaywallAction.VIDEO_ENDED); }); addListener("volumechange", () => { const nextMuted = !!localPlayer?.muted; setVideoMuted(nextMuted); emitVideoEvent( nextMuted ? NamiPaywallAction.VIDEO_MUTED : NamiPaywallAction.VIDEO_UNMUTED, ); }); addListener("error", (event?: any) => { const mediaError = localPlayer?.error; const targetError = event?.target?.mediaControlStateUtil?.mError; logger.warn("[NamiVideo][vega] VideoPlayer emitted error.", { id: component?.id, title: component?.title, url: activeUrl, eventType: event?.type, mediaErrorCode: typeof mediaError?.code === "number" ? mediaError.code : undefined, mediaErrorMessage: typeof mediaError?.message === "string" ? mediaError.message : undefined, readyState: typeof localPlayer?.readyState === "number" ? localPlayer.readyState : undefined, networkState: typeof localPlayer?.networkState === "number" ? localPlayer.networkState : undefined, currentSrc: typeof localPlayer?.currentSrc === "string" ? localPlayer.currentSrc : undefined, targetErrorCode: typeof targetError?.code_ === "number" ? targetError.code_ : undefined, targetErrorMessage: typeof targetError?.message_ === "string" ? targetError.message_ : undefined, }); handlePlaybackFailure(targetError ?? mediaError ?? event); }); try { localPlayer.autoplay = false; } catch { // no-op } try { localPlayer.muted = muted; } catch { // no-op } try { localPlayer.loop = loop; } catch { // no-op } try { if ("defaultSeekIntervalInSec" in localPlayer) { localPlayer.defaultSeekIntervalInSec = 10; } } catch { // no-op } localPlayer.src = activeUrl; try { localPlayer.pause?.(); } catch { // no-op } try { localPlayer.load?.(); } catch { // no-op } } catch (error) { handlePlaybackFailure(error); } }; initializePlayer(); return () => { disposedRef.current = true; setMetadataReady(false); setSurfaceReady(false); cleanup(); }; }, [ mediaLib, activeUrl, keplerAppStateManager, emitVideoEvent, handlePlaybackFailure, ]); useEffect(() => { const player = playerRef.current; if (!player || playbackFailed || !surfaceHandleRef.current) return; try { player.muted = muted; } catch { // no-op } try { player.loop = loop; } catch { // no-op } const syncPlayback = async () => { try { if (playing && metadataReady) { await player.play?.(); } else { await player.pause?.(); } } catch (error) { handlePlaybackFailure(error); } }; syncPlayback(); }, [ muted, loop, playing, metadataReady, playbackFailed, handlePlaybackFailure, ]); const handleSurfaceViewCreated = useCallback( async (surfaceHandle: string) => { surfaceHandleRef.current = surfaceHandle; setSurfaceReady(true); await attachSurfaceAndMaybePlay(); }, [attachSurfaceAndMaybePlay], ); const handleSurfaceViewDestroyed = useCallback((surfaceHandle: string) => { try { playerRef.current?.clearSurfaceHandle?.(surfaceHandle); } catch { // no-op } if (surfaceHandleRef.current === surfaceHandle) { surfaceHandleRef.current = null; } setSurfaceReady(false); }, []); if (!mediaLib) { return ( Video ); } if (!activeUrl) { return ( Missing video URL ); } if (playbackFailed && poster) { return ( ); } const { KeplerVideoSurfaceView } = mediaLib; return ( {!surfaceReady && poster ? ( ) : null} {metadataReady && !playbackFailed ? ( ) : null} ); }; const styles = StyleSheet.create({ poster: { ...StyleSheet.absoluteFillObject, }, surfaceContainer: { backgroundColor: "#000", overflow: "hidden", alignItems: "stretch", }, videoSurface: { zIndex: 0, }, placeholder: { backgroundColor: "#000", justifyContent: "center", alignItems: "center", }, placeholderText: { color: "#fff", fontSize: 14, }, });