UNPKG

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