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