unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
371 lines • 20.1 kB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
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 getFollowersLabel(n) {
return n === 1 ? 'Follower' : 'Followers';
}
const circleBtn = {
width: 40,
height: 40,
borderRadius: '50%',
background: 'rgba(0,0,0,0.35)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
cursor: 'pointer',
padding: 0,
WebkitTapHighlightColor: 'transparent',
pointerEvents: 'auto',
};
const engBtnStyle = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
color: '#fff',
WebkitTapHighlightColor: 'transparent',
pointerEvents: 'auto',
};
const countStyle = {
fontSize: 11,
fontWeight: 600,
color: '#fff',
textShadow: '0 1px 3px rgba(0,0,0,0.6)',
};
const REALTIME_SUBS_KEY = 'uvf_portrait_realtime_subscribed_creators';
function readRealtimeSubscribedCreators() {
if (typeof window === 'undefined')
return new Set();
try {
const raw = window.sessionStorage.getItem(REALTIME_SUBS_KEY);
if (!raw)
return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed))
return new Set();
return new Set(parsed.filter((v) => typeof v === 'string' && v.length > 0));
}
catch {
return new Set();
}
}
function persistRealtimeSubscribedCreators(set) {
if (typeof window === 'undefined')
return;
try {
window.sessionStorage.setItem(REALTIME_SUBS_KEY, JSON.stringify(Array.from(set)));
}
catch {
}
}
const realtimeSubscribedCreators = readRealtimeSubscribedCreators();
const subscribePendingCreators = new Set();
function getCreatorKey(video) {
return video.creator?.channelUrl || video.creator?.name || '';
}
export const PortraitOverlay = ({ video, muted, isPlaying, onPlayPause, onMuteToggle, onLike, onDislike, onNotInterested, onComment, onShare, onFollow, onCreatorClick, onMore, onTitleClick, hideEngagement, followLabel = 'Follow', showControls = true, showPlayPause = true, showMute = true, showLike = true, showDislike = true, showComment = true, showShare = true, showMore = true, showFollow = true, theme, }) => {
const [, setUiVersion] = useState(0);
const bumpUi = useCallback(() => setUiVersion((v) => v + 1), []);
const likeColor = theme?.likeColor || '#ff2d55';
const dislikeColor = theme?.dislikeColor || '#ff2d55';
const subscribedButtonColor = theme?.subscribedButtonColor || 'rgba(255,255,255,0.1)';
const creatorKey = getCreatorKey(video);
const isSubscribed = Boolean(video.creator?.isSubscribed);
const showRealtimeSubscribed = Boolean(creatorKey) && realtimeSubscribedCreators.has(creatorKey);
const followButtonLabel = video.creator?.isSubscribed ? 'Subscribed' : followLabel;
const prevCreatorKeyRef = useRef(creatorKey);
const initialSubscribedRef = useRef(Boolean(video.creator?.isSubscribed));
const prevSubscribedRef = useRef(Boolean(video.creator?.isSubscribed));
const handleLike = useCallback(() => onLike?.(video), [onLike, video]);
const handleDislike = useCallback(() => {
if (onDislike) {
onDislike(video);
return;
}
onNotInterested?.(video);
}, [onDislike, onNotInterested, video]);
const handleComment = useCallback(() => onComment?.(video), [onComment, video]);
const handleShare = useCallback(() => onShare?.(video), [onShare, video]);
const handleFollow = useCallback(async () => {
const hadMarker = Boolean(creatorKey) && realtimeSubscribedCreators.has(creatorKey);
const subscribeIntent = !hadMarker && !isSubscribed;
if (creatorKey && subscribePendingCreators.has(creatorKey))
return;
if (creatorKey) {
if (subscribeIntent) {
realtimeSubscribedCreators.add(creatorKey);
}
else {
realtimeSubscribedCreators.delete(creatorKey);
}
persistRealtimeSubscribedCreators(realtimeSubscribedCreators);
subscribePendingCreators.add(creatorKey);
bumpUi();
}
try {
if (onFollow) {
await Promise.resolve(onFollow(video));
return;
}
if (!subscribeIntent) {
throw new Error('Unsubscribe requires onFollow callback');
}
const subscribeUrl = video.creator?.subscribeUrl;
if (subscribeUrl) {
const opened = window.open(subscribeUrl, '_blank');
if (!opened) {
throw new Error('Failed to open subscribeUrl');
}
}
else {
throw new Error('Missing subscribeUrl');
}
}
catch (error) {
if (creatorKey) {
if (hadMarker) {
realtimeSubscribedCreators.add(creatorKey);
}
else {
realtimeSubscribedCreators.delete(creatorKey);
}
persistRealtimeSubscribedCreators(realtimeSubscribedCreators);
bumpUi();
}
console.error('[PortraitOverlay] Subscribe action failed, reverted optimistic state:', error);
}
finally {
if (creatorKey) {
subscribePendingCreators.delete(creatorKey);
bumpUi();
}
}
}, [onFollow, video, creatorKey, isSubscribed, bumpUi]);
const handleMore = useCallback(() => onMore?.(video), [onMore, video]);
const handleCreatorClick = useCallback(() => {
if (onCreatorClick) {
onCreatorClick(video);
return;
}
const channelUrl = video.creator?.channelUrl;
if (!channelUrl)
return;
const normalizedUrl = /^https?:\/\//i.test(channelUrl) ? channelUrl : `https://${channelUrl}`;
try {
window.open(normalizedUrl, '_blank');
}
catch (error) {
console.error('[PortraitOverlay] Failed to open channelUrl:', error);
}
}, [onCreatorClick, video]);
const isSubscribePending = Boolean(creatorKey) && subscribePendingCreators.has(creatorKey);
const isCreatorClickable = Boolean(onCreatorClick || video.creator?.channelUrl);
useEffect(() => {
if (prevCreatorKeyRef.current !== creatorKey) {
prevCreatorKeyRef.current = creatorKey;
initialSubscribedRef.current = isSubscribed;
prevSubscribedRef.current = isSubscribed;
return;
}
if (!initialSubscribedRef.current && !prevSubscribedRef.current && isSubscribed && creatorKey) {
realtimeSubscribedCreators.add(creatorKey);
persistRealtimeSubscribedCreators(realtimeSubscribedCreators);
bumpUi();
}
if (prevSubscribedRef.current && !isSubscribed && creatorKey) {
realtimeSubscribedCreators.delete(creatorKey);
persistRealtimeSubscribedCreators(realtimeSubscribedCreators);
bumpUi();
}
prevSubscribedRef.current = isSubscribed;
}, [creatorKey, isSubscribed, bumpUi]);
return (React.createElement(React.Fragment, null,
React.createElement("div", { style: {
position: 'absolute',
top: 16,
left: 16,
zIndex: 15,
display: 'flex',
gap: 8,
pointerEvents: 'none',
opacity: showControls ? 1 : 0,
transition: 'opacity 0.3s ease',
} },
showPlayPause && onPlayPause && (React.createElement("button", { "data-interactive": true, onClick: onPlayPause, style: circleBtn, "aria-label": isPlaying ? 'Pause' : 'Play' }, isPlaying ? (React.createElement("svg", { width: "18", height: "18", 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" }))) : (React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "#fff" },
React.createElement("polygon", { points: "5 3 19 12 5 21 5 3" }))))),
showMute && React.createElement("button", { "data-interactive": true, onClick: onMuteToggle, style: circleBtn, "aria-label": muted ? 'Unmute' : 'Mute' },
React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "#fff", strokeWidth: "2" }, muted ? (React.createElement(React.Fragment, null,
React.createElement("polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5", fill: "#fff" }),
React.createElement("line", { x1: "23", y1: "9", x2: "17", y2: "15" }),
React.createElement("line", { x1: "17", y1: "9", x2: "23", y2: "15" }))) : (React.createElement(React.Fragment, null,
React.createElement("polygon", { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5", fill: "#fff" }),
React.createElement("path", { d: "M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" })))))),
!hideEngagement && (React.createElement("div", { style: {
position: 'absolute',
right: 8,
bottom: 80,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
zIndex: 15,
pointerEvents: 'none',
} },
showLike && React.createElement("button", { "data-interactive": true, onClick: handleLike, style: engBtnStyle, "aria-label": "Like" },
React.createElement("div", { style: circleBtn },
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: countStyle }, formatCount(video.likes))),
showDislike && React.createElement("button", { "data-interactive": true, onClick: handleDislike, style: engBtnStyle, "aria-label": "Dislike" },
React.createElement("div", { style: circleBtn },
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: countStyle }, formatCount(video.dislikes))),
showComment && React.createElement("button", { "data-interactive": true, onClick: handleComment, style: engBtnStyle, "aria-label": "Comment" },
React.createElement("div", { style: circleBtn },
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: countStyle }, formatCount(video.comments))),
showShare && React.createElement("button", { "data-interactive": true, onClick: handleShare, style: engBtnStyle, "aria-label": "Share" },
React.createElement("div", { style: circleBtn },
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", { "data-interactive": true, onClick: handleMore, style: { ...engBtnStyle }, "aria-label": "More" },
React.createElement("div", { style: circleBtn },
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" })))))),
React.createElement("div", { style: {
position: 'absolute',
bottom: 20,
left: 12,
right: 70,
zIndex: 15,
pointerEvents: 'none',
} },
video.creator && (React.createElement(React.Fragment, null,
React.createElement("div", { "data-interactive": true, onClick: isCreatorClickable ? handleCreatorClick : undefined, style: {
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 6,
cursor: isCreatorClickable ? 'pointer' : 'default',
pointerEvents: 'auto',
} },
video.creator.avatar && (React.createElement("img", { src: video.creator.avatar, alt: "", style: { width: 36, height: 36, borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(255,255,255,0.5)' } })),
React.createElement("div", { style: { display: 'flex', flexDirection: 'column', gap: 1, flex: '1 1 auto', minWidth: 0 } },
React.createElement("div", { style: { display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 } },
React.createElement("span", { style: {
color: '#fff',
fontSize: 14,
fontWeight: 700,
textShadow: '0 1px 4px rgba(0,0,0,0.7)',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} }, video.creator.name),
video.creator.verified && (React.createElement("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none" },
React.createElement("circle", { cx: "8", cy: "8", r: "8", fill: "#1d9bf0" }),
React.createElement("path", { d: "M6.5 8.5l1.5 1.5 3-4", stroke: "#fff", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" })))),
video.creator.followers != null && (React.createElement("span", { style: {
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
textShadow: '0 1px 3px rgba(0,0,0,0.5)',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} },
formatCount(video.creator.followers),
" ",
getFollowersLabel(video.creator.followers)))),
showFollow && !isSubscribed && !showRealtimeSubscribed && (onFollow || video.creator?.subscribeUrl) && (React.createElement("button", { "data-interactive": true, disabled: isSubscribePending, onClick: (e) => {
e.stopPropagation();
handleFollow();
}, style: {
background: '#fff',
color: '#000',
border: 'none',
borderRadius: 20,
padding: '6px 16px',
fontSize: 13,
fontWeight: 700,
cursor: isSubscribePending ? 'wait' : 'pointer',
opacity: isSubscribePending ? 0.75 : 1,
marginLeft: 'auto',
whiteSpace: 'nowrap',
WebkitTapHighlightColor: 'transparent',
pointerEvents: 'auto',
} }, followButtonLabel)),
showFollow && showRealtimeSubscribed && (onFollow ? (React.createElement("button", { "data-interactive": true, disabled: isSubscribePending, onClick: (e) => {
e.stopPropagation();
handleFollow();
}, style: {
background: subscribedButtonColor,
color: '#fff',
border: 'none',
borderRadius: 20,
padding: '6px 16px',
fontSize: 13,
fontWeight: 700,
cursor: isSubscribePending ? 'wait' : 'pointer',
opacity: isSubscribePending ? 0.75 : 1,
marginLeft: 'auto',
whiteSpace: 'nowrap',
WebkitTapHighlightColor: 'transparent',
pointerEvents: 'auto',
} }, "Subscribed")) : (React.createElement("span", { style: {
background: subscribedButtonColor,
color: '#fff',
border: 'none',
borderRadius: 20,
padding: '6px 16px',
fontSize: 13,
fontWeight: 700,
marginLeft: 'auto',
whiteSpace: 'nowrap',
pointerEvents: 'none',
} }, "Subscribed")))))),
video.title && (React.createElement("p", { "data-interactive": true, onClick: onTitleClick, style: {
color: '#fff',
fontSize: 13,
margin: 0,
textShadow: '0 1px 4px rgba(0,0,0,0.7)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
cursor: onTitleClick ? 'pointer' : 'default',
pointerEvents: onTitleClick ? 'auto' : 'none',
} }, video.title))),
React.createElement("div", { style: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 200,
background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
pointerEvents: 'none',
zIndex: 10,
} })));
};
//# sourceMappingURL=PortraitOverlay.js.map