UNPKG

unified-video-framework

Version:

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

473 lines 25.4 kB
import React, { useRef, useState, useEffect, useCallback, useImperativeHandle } from 'react'; import { PortraitVideoCard } from './components/portrait/PortraitVideoCard.js'; import { PortraitDescriptionPanel } from './components/portrait/PortraitDescriptionPanel.js'; const STYLE_ID = 'uvf-portrait-keyframes'; function ensureStyles() { if (typeof document === 'undefined') return; if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` @keyframes uvf-like-burst { 0% { transform: scale(0) rotate(-15deg); opacity: 1; } 30% { transform: scale(1.4) rotate(10deg); opacity: 1; } 50% { transform: scale(0.95) rotate(-5deg); opacity: 1; } 70% { transform: scale(1.1) rotate(0deg); opacity: 0.8; } 100% { transform: scale(1) rotate(0deg); opacity: 0; } } @keyframes uvf-like-ring { 0% { transform: scale(0); opacity: 0.6; border-width: 4px; } 60% { transform: scale(2.2); opacity: 0.2; border-width: 1px; } 100% { transform: scale(2.8); opacity: 0; border-width: 0px; } } @keyframes uvf-like-particle-1 { 0% { transform: translate(0,0) scale(1); opacity: 1; } 100% { transform: translate(-30px,-50px) scale(0.4); opacity: 0; } } @keyframes uvf-like-particle-2 { 0% { transform: translate(0,0) scale(1); opacity: 1; } 100% { transform: translate(35px,-45px) scale(0.3); opacity: 0; } } @keyframes uvf-like-particle-3 { 0% { transform: translate(0,0) scale(1); opacity: 1; } 100% { transform: translate(-40px,20px) scale(0.3); opacity: 0; } } @keyframes uvf-like-particle-4 { 0% { transform: translate(0,0) scale(1); opacity: 1; } 100% { transform: translate(30px,35px) scale(0.4); opacity: 0; } } @keyframes uvf-like-particle-5 { 0% { transform: translate(0,0) scale(1); opacity: 1; } 100% { transform: translate(0px,-60px) scale(0.3); opacity: 0; } } @keyframes uvf-icon-flash { 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0.9; } 60% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.7; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 0; } } @keyframes uvf-spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .uvf-portrait-feed::-webkit-scrollbar { display: none; } .uvf-portrait-feed { scrollbar-width: none; -ms-overflow-style: none; } `; document.head.appendChild(style); } function formatCount(n) { if (!n) return '0'; if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; return String(n); } function parseAspectRatioToken(value) { const raw = (value || '9:16').toString().trim().toLowerCase(); if (!raw || raw === 'auto') return 9 / 16; if (raw === '9:16' || raw === '9/16') return 9 / 16; if (raw === '1:1' || raw === '1/1') return 1; if (raw === '16:9' || raw === '16/9') return 16 / 9; const sep = raw.includes(':') ? ':' : (raw.includes('/') ? '/' : null); if (!sep) return 9 / 16; const [w, h] = raw.split(sep).map((v) => Number(v.trim())); if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return 9 / 16; return w / h; } const EngagementColumn = ({ video, onLike, onDislike, onNotInterested, onComment, onShare, onMore, showLike = true, showDislike = true, showComment = true, showShare = true, showMore = true, moreButtonRef, theme, }) => ((() => { const likeColor = theme?.likeColor || '#ff2d55'; const dislikeColor = theme?.dislikeColor || '#ff2d55'; return React.createElement("div", { style: { width: 64, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', alignItems: 'center', paddingBottom: 20, gap: 8, flexShrink: 0, } }, showLike && React.createElement("button", { "data-interactive": true, onClick: () => onLike?.(video), style: engBtnStyle, "aria-label": "Like" }, React.createElement("div", { style: engCircleStyle }, React.createElement("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: video.isLiked ? likeColor : 'none', stroke: video.isLiked ? likeColor : '#fff', strokeWidth: "2" }, 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" }))), React.createElement("span", { style: engLabelStyle }, formatCount(video.likes))), showDislike && React.createElement("button", { "data-interactive": true, onClick: () => (onDislike ? onDislike(video) : onNotInterested?.(video)), style: engBtnStyle, "aria-label": "Dislike" }, React.createElement("div", { style: engCircleStyle }, React.createElement("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: video.isDisliked ? dislikeColor : 'none', stroke: video.isDisliked ? dislikeColor : '#fff', strokeWidth: "2" }, React.createElement("path", { d: "M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" }))), React.createElement("span", { style: engLabelStyle }, formatCount(video.dislikes))), showComment && React.createElement("button", { "data-interactive": true, onClick: () => onComment?.(video), style: engBtnStyle, "aria-label": "Comment" }, React.createElement("div", { style: engCircleStyle }, React.createElement("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2" }, React.createElement("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" }))), React.createElement("span", { style: engLabelStyle }, formatCount(video.comments))), showShare && React.createElement("button", { "data-interactive": true, onClick: () => onShare?.(video), style: engBtnStyle, "aria-label": "Share" }, React.createElement("div", { style: engCircleStyle }, React.createElement("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2" }, React.createElement("path", { d: "M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" }), React.createElement("polyline", { points: "16 6 12 2 8 6" }), React.createElement("line", { x1: "12", y1: "2", x2: "12", y2: "15" })))), showMore && React.createElement("button", { ref: moreButtonRef, "data-interactive": true, onClick: () => onMore?.(video), style: engBtnStyle, "aria-label": "More" }, React.createElement("div", { style: engCircleStyle }, React.createElement("svg", { width: "22", height: "22", viewBox: "0 0 24 24", fill: "#fff" }, React.createElement("circle", { cx: "12", cy: "5", r: "1.5" }), React.createElement("circle", { cx: "12", cy: "12", r: "1.5" }), React.createElement("circle", { cx: "12", cy: "19", r: "1.5" }))))); })()); const NavArrows = ({ onUp, onDown, canUp, canDown }) => (React.createElement("div", { style: { height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, flexShrink: 0, } }, React.createElement("button", { "data-interactive": true, onClick: onUp, style: { ...navArrowStyle, ...(canUp ? null : navArrowDisabledStyle) }, "aria-label": "Previous", disabled: !canUp }, React.createElement("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }, React.createElement("polyline", { points: "18 15 12 9 6 15" }))), React.createElement("button", { "data-interactive": true, onClick: onDown, style: { ...navArrowStyle, ...(canDown ? null : navArrowDisabledStyle) }, "aria-label": "Next", disabled: !canDown }, React.createElement("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }, React.createElement("polyline", { points: "6 9 12 15 18 9" }))))); const navArrowStyle = { width: 48, height: 48, borderRadius: '50%', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#fff', padding: 0, WebkitTapHighlightColor: 'transparent', }; const navArrowDisabledStyle = { opacity: 0.4, cursor: 'not-allowed', }; const engCircleStyle = { width: 48, height: 48, borderRadius: '50%', background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', }; const engBtnStyle = { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, background: 'none', border: 'none', color: '#fff', cursor: 'pointer', padding: 0, WebkitTapHighlightColor: 'transparent', }; const engLabelStyle = { fontSize: 12, fontWeight: 600, color: '#fff', }; export const PortraitPlayerView = ({ videos, aspectRatio = '9:16', width = '100%', height = '100%', autoPlay = true, muted: initialMuted = true, loop = true, autoAdvance = false, preloadCount = 1, onVideoChange, onLike, onDislike, onComment, onShare, onFollow, onCreatorClick, onMore, onReport, onNotInterested, onCopyLink, onWishlist, onWatchLater, followLabel = 'Follow', 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, theme, playerRef, enableUrlRouting = false, urlPattern = 'path', baseUrl = '/video', getVideoUrl, initialVideoId, }) => { const feedRef = useRef(null); const moreButtonRef = useRef(null); const wheelLockUntilRef = useRef(0); const [muted, setMuted] = useState(initialMuted); const [isDesktop, setIsDesktop] = useState(typeof window !== 'undefined' ? window.innerWidth >= 768 : false); const [moreMenuVideoIndex, setMoreMenuVideoIndex] = useState(null); const [descriptionPanelVideoIndex, setDescriptionPanelVideoIndex] = useState(null); const isUrlRoutingEnabled = enableUrlRouting && typeof window !== 'undefined'; const updatingFromUrlRef = useRef(false); const getInitialIndex = useCallback(() => { if (typeof window === 'undefined') return 0; if (initialVideoId) { const idx = videos.findIndex(v => v.id === initialVideoId); if (idx !== -1) return idx; } if (isUrlRoutingEnabled) { const videoId = urlPattern === 'hash' ? window.location.hash.slice(1).split('/').pop() : window.location.pathname.split('/').pop(); if (videoId) { const idx = videos.findIndex(v => v.id === videoId); if (idx !== -1) return idx; } } return 0; }, [videos, initialVideoId, isUrlRoutingEnabled, urlPattern]); const [activeIndex, setActiveIndex] = useState(getInitialIndex); useEffect(() => { ensureStyles(); }, []); useEffect(() => { if (typeof window === 'undefined') return; const mq = window.matchMedia('(min-width: 768px)'); const handler = (e) => setIsDesktop(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); useEffect(() => { if (!isUrlRoutingEnabled || updatingFromUrlRef.current) return; const video = videos[activeIndex]; if (!video) return; const newUrl = getVideoUrl ? getVideoUrl(video) : urlPattern === 'hash' ? `#${baseUrl}/${video.id}` : `${baseUrl}/${video.id}`; const currentUrl = urlPattern === 'hash' ? window.location.hash : window.location.pathname; const targetUrl = urlPattern === 'hash' ? newUrl : newUrl; if (currentUrl !== targetUrl) { if (urlPattern === 'hash') { window.history.replaceState({ videoId: video.id, index: activeIndex }, '', newUrl); } else { window.history.replaceState({ videoId: video.id, index: activeIndex }, '', newUrl); } } }, [activeIndex, videos, isUrlRoutingEnabled, urlPattern, baseUrl, getVideoUrl]); useEffect(() => { if (!isUrlRoutingEnabled) return; const handlePopState = (event) => { updatingFromUrlRef.current = true; const videoId = event.state?.videoId || (urlPattern === 'hash' ? window.location.hash.slice(1).split('/').pop() : window.location.pathname.split('/').pop()); if (videoId) { const idx = videos.findIndex(v => v.id === videoId); if (idx !== -1 && idx !== activeIndex) { scrollToIndex(idx); } } setTimeout(() => { updatingFromUrlRef.current = false; }, 300); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, [videos, activeIndex, isUrlRoutingEnabled, urlPattern]); useEffect(() => { const feed = feedRef.current; if (!feed) return; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio > 0.6) { const idx = Number(entry.target.dataset.index); if (!isNaN(idx)) { setActiveIndex((prev) => { if (prev !== idx) { onVideoChange?.(idx, videos[idx]); return idx; } return prev; }); } } }); }, { root: feed, threshold: [0, 0.6, 1.0] }); const cards = feed.querySelectorAll('[data-portrait-card]'); cards.forEach((card) => observer.observe(card)); return () => observer.disconnect(); }, [videos, onVideoChange]); const scrollToIndex = useCallback((index) => { const feed = feedRef.current; if (!feed) return; const card = feed.children[index]; card?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); const handleDesktopWheelNavigate = useCallback((e) => { if (!isDesktop) return; const feed = feedRef.current; const target = e.target; if (!feed || !target) return; if (feed.contains(target)) return; if (target instanceof Element && target.closest('[data-uvf-description-scroll]')) return; if (Math.abs(e.deltaY) < 18) return; const now = Date.now(); if (now < wheelLockUntilRef.current) { e.preventDefault(); return; } e.preventDefault(); const nextIndex = e.deltaY > 0 ? Math.min(activeIndex + 1, videos.length - 1) : Math.max(activeIndex - 1, 0); if (nextIndex !== activeIndex) { wheelLockUntilRef.current = now + 320; scrollToIndex(nextIndex); } }, [isDesktop, activeIndex, videos.length, scrollToIndex]); useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); scrollToIndex(Math.min(activeIndex + 1, videos.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); scrollToIndex(Math.max(activeIndex - 1, 0)); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [activeIndex, videos.length, scrollToIndex]); useImperativeHandle(playerRef, () => ({ next() { scrollToIndex(Math.min(activeIndex + 1, videos.length - 1)); }, prev() { scrollToIndex(Math.max(activeIndex - 1, 0)); }, goTo(index) { if (index >= 0 && index < videos.length) scrollToIndex(index); }, getCurrentIndex() { return activeIndex; }, }), [activeIndex, videos.length, scrollToIndex]); const handleMuteToggle = useCallback(() => setMuted((m) => !m), []); const handleEnded = useCallback((index) => { if (autoAdvance && index < videos.length - 1) { scrollToIndex(index + 1); } }, [autoAdvance, videos.length, scrollToIndex]); const handleOpenMoreMenu = useCallback((index, video) => { setMoreMenuVideoIndex(index); onMore?.(video); }, [onMore]); const handleCloseMoreMenu = useCallback(() => { setMoreMenuVideoIndex(null); }, []); const isDescriptionPanelOpen = isDesktop && descriptionPanelVideoIndex != null; const descriptionPanelVideo = descriptionPanelVideoIndex != null ? videos[descriptionPanelVideoIndex] : null; useEffect(() => { if (!isDesktop) return; setDescriptionPanelVideoIndex((prev) => { if (prev == null) return prev; if (prev === activeIndex) return prev; return activeIndex; }); }, [isDesktop, activeIndex]); return (React.createElement("div", { style: { width, height, display: 'flex', justifyContent: 'center', alignItems: 'stretch', background: '#000', position: 'relative', }, onWheelCapture: handleDesktopWheelNavigate }, React.createElement("div", { style: { width: '100%', height: '100%', display: 'flex', alignItems: 'stretch', justifyContent: 'center', gap: isDesktop ? 12 : 0, } }, React.createElement("div", { ref: feedRef, className: "uvf-portrait-feed", style: { width: '100%', maxWidth: isDesktop ? 'min(96vw, 760px)' : undefined, height: '100%', overflowY: 'scroll', scrollSnapType: 'y mandatory', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', position: 'relative', background: '#000', flexShrink: 0, } }, videos.map((video, index) => { const isActive = index === activeIndex && autoPlay; const shouldPreload = !isActive && Math.abs(index - activeIndex) <= preloadCount; const cardRatio = parseAspectRatioToken(video.aspectRatio || aspectRatio); const desktopVideoMaxWidth = cardRatio >= 1 ? `min(calc((100dvh - 32px) * ${cardRatio}), ${isDescriptionPanelOpen ? 520 : 620}px)` : `min(calc((100dvh - 32px) * ${cardRatio}), ${isDescriptionPanelOpen ? 420 : 460}px)`; return (React.createElement("div", { key: video.id, "data-portrait-card": true, "data-index": index, style: { width: '100%', height: '100%', scrollSnapAlign: 'start', flexShrink: 0, display: isDesktop ? 'flex' : 'block', alignItems: isDesktop ? 'center' : undefined, justifyContent: isDesktop ? 'center' : undefined, gap: isDesktop ? 8 : undefined, } }, React.createElement("div", { style: { flex: isDesktop ? '0 1 auto' : undefined, maxWidth: isDesktop ? desktopVideoMaxWidth : undefined, width: isDesktop ? 'calc(100% - 72px)' : '100%', height: '100%', position: 'relative', borderRadius: isDesktop ? 12 : 0, overflow: 'hidden', } }, React.createElement(PortraitVideoCard, { video: video, isActive: isActive, shouldPreload: shouldPreload, muted: muted, loop: loop, autoAdvance: autoAdvance, aspectRatio: aspectRatio, onMuteToggle: handleMuteToggle, onLike: onLike, onDislike: onDislike, onComment: onComment, onShare: onShare, onFollow: onFollow, onCreatorClick: onCreatorClick, onMore: onMore, onReport: onReport, onNotInterested: onNotInterested, onCopyLink: onCopyLink, onWishlist: onWishlist, onWatchLater: onWatchLater, followLabel: followLabel, showPlayPause: showPlayPause, showMute: showMute, showLike: showLike, showDislike: showDislike, showComment: showComment, showShare: showShare, showMore: showMore, showFollow: showFollow, showDescription: showDescription, showReport: showReport, showNotInterested: showNotInterested, showCopyLink: showCopyLink, showWishlist: showWishlist, showWatchLater: showWatchLater, theme: theme, isDescriptionPanelOpenExternal: descriptionPanelVideoIndex === index, onDescriptionPanelChange: (isOpen) => { setDescriptionPanelVideoIndex((prev) => { if (isOpen) return index; if (prev === index) return null; return prev; }); }, onEnded: () => handleEnded(index), hideEngagement: isDesktop, forceShowMoreMenu: moreMenuVideoIndex === index, onCloseMoreMenu: handleCloseMoreMenu, moreButtonRef: moreButtonRef })), isDesktop && (React.createElement(EngagementColumn, { video: video, onLike: onLike, onDislike: onDislike, onNotInterested: onNotInterested, onComment: onComment, onShare: onShare, onMore: () => handleOpenMoreMenu(index, video), showLike: showLike, showDislike: showDislike, showComment: showComment, showShare: showShare, showMore: showMore, moreButtonRef: moreButtonRef, theme: theme })))); })), isDesktop && (React.createElement(React.Fragment, null, React.createElement("div", { style: { width: isDescriptionPanelOpen ? 500 : 0, transition: 'width 260ms cubic-bezier(0.22, 1, 0.36, 1)', overflow: 'hidden', flexShrink: 0, height: '100%', } }, React.createElement("div", { style: { width: 500, height: '100%', opacity: isDescriptionPanelOpen ? 1 : 0, transition: 'opacity 180ms ease', pointerEvents: isDescriptionPanelOpen ? 'auto' : 'none', } }, descriptionPanelVideo && (React.createElement(PortraitDescriptionPanel, { video: descriptionPanelVideo, isOpen: isDescriptionPanelOpen, onClose: () => setDescriptionPanelVideoIndex(null), desktopInline: true })))), isDescriptionPanelOpen && (React.createElement("div", { style: { width: 64, flexShrink: 0 } }, React.createElement(NavArrows, { onUp: () => scrollToIndex(Math.max(activeIndex - 1, 0)), onDown: () => scrollToIndex(Math.min(activeIndex + 1, videos.length - 1)), canUp: activeIndex > 0, canDown: activeIndex < videos.length - 1 })))))), isDesktop && !isDescriptionPanelOpen && (React.createElement("div", { style: { position: 'absolute', right: 24, top: '50%', transform: 'translateY(-50%)', zIndex: 30, } }, React.createElement(NavArrows, { onUp: () => scrollToIndex(Math.max(activeIndex - 1, 0)), onDown: () => scrollToIndex(Math.min(activeIndex + 1, videos.length - 1)), canUp: activeIndex > 0, canDown: activeIndex < videos.length - 1 }))))); }; //# sourceMappingURL=PortraitPlayerView.js.map