UNPKG

unified-video-framework

Version:

Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more

429 lines 19.7 kB
import React, { useRef, useEffect, useState, useCallback } from 'react'; import { PortraitOverlay } from './PortraitOverlay.js'; import { PortraitProgressBar } from './PortraitProgressBar.js'; import { PortraitDescriptionPanel } from './PortraitDescriptionPanel.js'; import { PortraitMoreMenu } from './PortraitMoreMenu.js'; import { usePortraitGestures } from './usePortraitGestures.js'; async function ensureHls() { if (window.Hls) return window.Hls; return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest'; s.onload = () => resolve(window.Hls); s.onerror = reject; document.head.appendChild(s); }); } function isHlsUrl(url) { return /\.m3u8(\?|$)/i.test(url); } function toCssAspectRatio(value) { if (!value) return null; const v = String(value).trim().toLowerCase(); if (!v) return null; if (v === 'auto') return 'auto'; if (v === '9:16' || v === '9/16') return '9/16'; if (v === '1:1' || v === '1/1') return '1/1'; if (v === '16:9' || v === '16/9') return '16/9'; const sep = v.includes(':') ? ':' : (v.includes('/') ? '/' : null); if (!sep) return null; const parts = v.split(sep).map((x) => Number(x.trim())); if (parts.length !== 2) return null; const [w, h] = parts; if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null; return `${w}/${h}`; } export const PortraitVideoCard = ({ video, isActive, shouldPreload, muted, loop, autoAdvance, aspectRatio, onMuteToggle, onLike, onDislike, onComment, onShare, onFollow, onCreatorClick, onMore, onReport, onNotInterested, onCopyLink, onWishlist, onWatchLater, onEnded, hideEngagement, followLabel, showPlayPause = true, showMute = true, showLike = true, showDislike = true, showComment = true, showShare = true, showMore = true, showFollow = true, showDescription = true, showReport = true, showNotInterested = true, showCopyLink = true, showWishlist = true, showWatchLater = true, isDescriptionPanelOpenExternal = false, onDescriptionPanelChange, forceShowMoreMenu = false, onCloseMoreMenu, moreButtonRef, theme, }) => { const containerRef = useRef(null); const videoRef = useRef(null); const hlsRef = useRef(null); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [buffered, setBuffered] = useState(0); const [showPlayIcon, setShowPlayIcon] = useState(false); const [showPauseIcon, setShowPauseIcon] = useState(false); const [heartAnim, setHeartAnim] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [naturalRatio, setNaturalRatio] = useState(null); const [isBuffering, setIsBuffering] = useState(false); const [showControls, setShowControls] = useState(false); const [is2xSpeed, setIs2xSpeed] = useState(false); const [showDescriptionPanel, setShowDescriptionPanel] = useState(false); const [internalShowMoreMenu, setInternalShowMoreMenu] = useState(false); const heartIdRef = useRef(0); const longPressTimerRef = useRef(null); const longPressTriggeredRef = useRef(false); const initializedRef = useRef(false); const loopRef = useRef(loop); const autoAdvanceRef = useRef(autoAdvance); const descriptionOpenRef = useRef(false); const showMoreMenu = forceShowMoreMenu || internalShowMoreMenu; const handleMoreClick = useCallback(() => { setInternalShowMoreMenu(true); onMore?.(video); }, [onMore, video]); const handleCloseMoreMenuInternal = useCallback(() => { setInternalShowMoreMenu(false); onCloseMoreMenu?.(); }, [onCloseMoreMenu]); const effectiveRatio = video.aspectRatio || aspectRatio; const normalizedAspect = toCssAspectRatio(effectiveRatio) || '9/16'; const resolvedAspect = normalizedAspect === 'auto' ? (naturalRatio || '9/16') : normalizedAspect; const likeColor = theme?.likeColor || '#ff2d55'; const likeAnimationColor = theme?.likeAnimationColor || likeColor; useEffect(() => { loopRef.current = loop; }, [loop]); useEffect(() => { autoAdvanceRef.current = autoAdvance; }, [autoAdvance]); const isDescriptionPanelOpen = hideEngagement ? isDescriptionPanelOpenExternal : showDescriptionPanel; useEffect(() => { descriptionOpenRef.current = isDescriptionPanelOpen; onDescriptionPanelChange?.(isDescriptionPanelOpen); const vid = videoRef.current; if (!vid) return; vid.loop = isDescriptionPanelOpen ? true : loop; }, [isDescriptionPanelOpen, loop, onDescriptionPanelChange]); useEffect(() => { if (!containerRef.current) return; if (!isActive && !shouldPreload) return; if (initializedRef.current) return; initializedRef.current = true; const vid = document.createElement('video'); vid.playsInline = true; vid.muted = muted; vid.loop = loop; vid.preload = isActive ? 'auto' : 'metadata'; vid.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0;'; if (video.posterUrl) vid.poster = video.posterUrl; containerRef.current.prepend(vid); videoRef.current = vid; vid.addEventListener('timeupdate', () => { setCurrentTime(vid.currentTime); }); vid.addEventListener('loadedmetadata', () => { setDuration(vid.duration || 0); if (vid.videoWidth && vid.videoHeight) { setNaturalRatio(`${vid.videoWidth}/${vid.videoHeight}`); } }); vid.addEventListener('progress', () => { if (vid.buffered.length > 0) { setBuffered(vid.buffered.end(vid.buffered.length - 1)); } }); vid.addEventListener('waiting', () => setIsBuffering(true)); vid.addEventListener('playing', () => setIsBuffering(false)); vid.addEventListener('play', () => setIsPlaying(true)); vid.addEventListener('pause', () => setIsPlaying(false)); vid.addEventListener('ended', () => { setIsPlaying(false); if (descriptionOpenRef.current) return; if (!loopRef.current && autoAdvanceRef.current) onEnded(); }); if (isHlsUrl(video.url)) { ensureHls().then((Hls) => { if (!Hls.isSupported()) { vid.src = video.url; if (isActive) vid.play().catch(() => { }); return; } const hls = new Hls({ enableWorker: true }); hlsRef.current = hls; hls.loadSource(video.url); hls.attachMedia(vid); hls.on(Hls.Events.MANIFEST_PARSED, () => { if (isActive) vid.play().catch(() => { }); }); }).catch((err) => { console.error('[PortraitVideoCard] HLS load error', err); vid.src = video.url; if (isActive) vid.play().catch(() => { }); }); } else { vid.src = video.url; if (isActive) { vid.addEventListener('canplay', () => vid.play().catch(() => { }), { once: true }); } } return () => { initializedRef.current = false; if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } vid.pause(); vid.removeAttribute('src'); vid.load(); vid.remove(); videoRef.current = null; }; }, [isActive, shouldPreload, video.url]); useEffect(() => { if (videoRef.current) videoRef.current.muted = muted; }, [muted]); useEffect(() => { const vid = videoRef.current; if (!vid || !initializedRef.current) return; if (isActive) { vid.play().catch(() => { }); } else { vid.pause(); if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } vid.playbackRate = 1; longPressTriggeredRef.current = false; setIs2xSpeed(false); } }, [isActive]); const flashIcon = useCallback((type) => { if (type === 'play') { setShowPlayIcon(true); setTimeout(() => setShowPlayIcon(false), 500); } else { setShowPauseIcon(true); setTimeout(() => setShowPauseIcon(false), 500); } }, []); const handleSeek = useCallback((time) => { const vid = videoRef.current; if (!vid) return; vid.currentTime = time; setCurrentTime(time); }, []); const handlePointerDown = useCallback((e) => { const target = e.target; if (target.closest('button, a, input, textarea, select, [data-interactive]')) return; const vid = videoRef.current; if (!vid || !isPlaying) return; longPressTriggeredRef.current = false; e.currentTarget.setPointerCapture?.(e.pointerId); longPressTimerRef.current = window.setTimeout(() => { vid.playbackRate = 2; longPressTriggeredRef.current = true; setIs2xSpeed(true); }, 200); }, [isPlaying]); const handlePointerUp = useCallback((e) => { if (e) e.currentTarget.releasePointerCapture?.(e.pointerId); if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } const vid = videoRef.current; if (!vid) return; vid.playbackRate = 1; setIs2xSpeed(false); }, []); useEffect(() => { return () => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } const vid = videoRef.current; if (vid) vid.playbackRate = 1; }; }, []); useEffect(() => { setShowControls(!isPlaying); }, [isPlaying]); const { handleTap } = usePortraitGestures(containerRef, { onSingleTap: () => { if (longPressTriggeredRef.current) { longPressTriggeredRef.current = false; return; } const vid = videoRef.current; if (!vid) return; if (isPlaying) { vid.pause(); flashIcon('pause'); } else { vid.play().catch(() => { }); flashIcon('play'); } }, onDoubleTap: (x, y) => { const id = ++heartIdRef.current; setHeartAnim({ x, y, id }); setTimeout(() => setHeartAnim(prev => prev?.id === id ? null : prev), 1100); if (!video.isLiked) { onLike?.(video); } }, }); const openDescriptionPanel = useCallback(() => { if (hideEngagement) { onDescriptionPanelChange?.(true); return; } setShowDescriptionPanel(true); }, [hideEngagement, onDescriptionPanelChange]); const closeDescriptionPanel = useCallback(() => { if (hideEngagement) { onDescriptionPanelChange?.(false); return; } setShowDescriptionPanel(false); }, [hideEngagement, onDescriptionPanelChange]); return (React.createElement("div", { className: "uvf-portrait-card", style: { width: '100%', height: '100%', scrollSnapAlign: 'start', position: 'relative', overflow: 'hidden', background: '#000', display: 'flex', justifyContent: 'center', alignItems: 'center', flexShrink: 0, } }, React.createElement("div", { style: { width: '100%', maxHeight: '100%', aspectRatio: resolvedAspect, position: 'relative', overflow: 'hidden', background: '#000', transition: 'aspect-ratio 0.3s ease', } }, React.createElement("div", { ref: containerRef, onClick: handleTap, onPointerDown: handlePointerDown, onPointerUp: handlePointerUp, onPointerLeave: handlePointerUp, onPointerCancel: handlePointerUp, onLostPointerCapture: handlePointerUp, style: { position: 'absolute', inset: 0, zIndex: 1 } }), video.posterUrl && !isActive && !shouldPreload && (React.createElement("img", { src: video.posterUrl, alt: "", style: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', zIndex: 0 } })), isBuffering && isActive && (React.createElement("div", { style: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 25, pointerEvents: 'none', } }, React.createElement("div", { style: { width: 44, height: 44, border: '3px solid rgba(255,255,255,0.3)', borderTopColor: '#fff', borderRadius: '50%', animation: 'uvf-spinner 0.8s linear infinite', } }))), showPlayIcon && (React.createElement("div", { style: iconFlashStyle }, React.createElement("svg", { width: "60", height: "60", viewBox: "0 0 24 24", fill: "#fff" }, React.createElement("polygon", { points: "5 3 19 12 5 21 5 3" })))), showPauseIcon && (React.createElement("div", { style: iconFlashStyle }, React.createElement("svg", { width: "60", height: "60", viewBox: "0 0 24 24", fill: "#fff" }, React.createElement("rect", { x: "6", y: "4", width: "4", height: "16" }), React.createElement("rect", { x: "14", y: "4", width: "4", height: "16" })))), is2xSpeed && (React.createElement("div", { style: { position: 'absolute', top: 16, right: 16, zIndex: 26, background: 'rgba(0,0,0,0.7)', borderRadius: 6, padding: '6px 12px', pointerEvents: 'none', } }, React.createElement("span", { style: { color: '#fff', fontSize: 14, fontWeight: 700, } }, "2\u00D7"))), heartAnim && (React.createElement("div", { key: heartAnim.id, style: { position: 'absolute', left: heartAnim.x, top: heartAnim.y, zIndex: 30, pointerEvents: 'none', } }, React.createElement("div", { style: { position: 'absolute', left: -36, top: -36, width: 72, height: 72, borderRadius: '50%', border: `4px solid ${likeAnimationColor}`, animation: 'uvf-like-ring 0.7s ease-out forwards', } }), React.createElement("div", { style: { position: 'absolute', left: -36, top: -36, animation: 'uvf-like-burst 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) forwards', } }, React.createElement("svg", { width: "72", height: "72", viewBox: "0 0 24 24", fill: likeAnimationColor, stroke: "none" }, React.createElement("path", { d: "M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z" }), React.createElement("path", { d: "M7 22H4.33A2.31 2.31 0 0 1 2 20v-7a2.31 2.31 0 0 1 2.33-2H7" }))), [1, 2, 3, 4, 5].map(i => (React.createElement("div", { key: i, style: { position: 'absolute', left: -10, top: -10, animation: `uvf-like-particle-${i} 0.7s ease-out ${0.05 * i}s forwards`, opacity: 0, animationFillMode: 'forwards', } }, React.createElement("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: likeAnimationColor, stroke: "none", opacity: "0.8" }, React.createElement("path", { d: "M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z" }), React.createElement("path", { d: "M7 22H4.33A2.31 2.31 0 0 1 2 20v-7a2.31 2.31 0 0 1 2.33-2H7" }))))))), isActive && (React.createElement(PortraitOverlay, { video: video, muted: muted, isPlaying: isPlaying, onPlayPause: () => { const vid = videoRef.current; if (!vid) return; if (isPlaying) { vid.pause(); } else { vid.play().catch(() => { }); } }, onMuteToggle: onMuteToggle, onLike: onLike, onDislike: onDislike, onNotInterested: onNotInterested, onComment: onComment, onShare: onShare, onFollow: onFollow, onCreatorClick: onCreatorClick, onMore: handleMoreClick, onTitleClick: openDescriptionPanel, hideEngagement: hideEngagement, followLabel: followLabel, showControls: showControls, showPlayPause: showPlayPause, showMute: showMute, showLike: showLike, showDislike: showDislike, showComment: showComment, showShare: showShare, showMore: showMore, showFollow: showFollow, theme: theme })), isActive && (React.createElement(PortraitProgressBar, { currentTime: currentTime, duration: duration, buffered: buffered, isPlaying: isPlaying, onSeek: handleSeek }))), !hideEngagement && (React.createElement(PortraitDescriptionPanel, { video: video, isOpen: showDescriptionPanel, onClose: closeDescriptionPanel })), React.createElement(PortraitMoreMenu, { video: video, isOpen: showMoreMenu, onClose: handleCloseMoreMenuInternal, onDescription: openDescriptionPanel, onReport: onReport, onNotInterested: onNotInterested, onCopyLink: onCopyLink, onWishlist: onWishlist, onWatchLater: onWatchLater, showDescription: showDescription, showReport: showReport, showNotInterested: showNotInterested, showCopyLink: showCopyLink, showWishlist: showWishlist, showWatchLater: showWatchLater, moreButtonRef: moreButtonRef, theme: theme }))); }; const iconFlashStyle = { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 25, pointerEvents: 'none', opacity: 0.8, animation: 'uvf-icon-flash 0.5s ease-out forwards', }; //# sourceMappingURL=PortraitVideoCard.js.map