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
JavaScript
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