UNPKG

react-vertical-feed

Version:

A React component for creating vertical media feeds (videos) similar to TikTok or Instagram

178 lines (174 loc) 8.21 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var react = require('react'); const VerticalFeed = react.forwardRef(({ items, onEndReached, loadingComponent, errorComponent, className, style, onItemVisible, onItemHidden, onItemClick, threshold = 0.75, scrollBehavior = 'smooth', renderItemOverlay, endReachedThreshold = 100, onVideoError, onCurrentItemChange, defaultPreload = 'metadata', }, ref) => { const containerRef = react.useRef(null); const [loadingStates, setLoadingStates] = react.useState({}); const [errorStates, setErrorStates] = react.useState({}); const currentIndexRef = react.useRef(0); const endReachedCalledRef = react.useRef(false); // Stable refs for callbacks to avoid recreating IntersectionObserver const onItemVisibleRef = react.useRef(onItemVisible); const onItemHiddenRef = react.useRef(onItemHidden); const onVideoErrorRef = react.useRef(onVideoError); const onCurrentItemChangeRef = react.useRef(onCurrentItemChange); react.useLayoutEffect(() => { onItemVisibleRef.current = onItemVisible; onItemHiddenRef.current = onItemHidden; onVideoErrorRef.current = onVideoError; onCurrentItemChangeRef.current = onCurrentItemChange; }); // Imperative handle for programmatic control react.useImperativeHandle(ref, () => ({ scrollToItem: (index, behavior = scrollBehavior) => { if (!containerRef.current || index < 0 || index >= items.length) return; const targetElement = containerRef.current.querySelector(`[data-index="${index}"]`); if (targetElement) { targetElement.scrollIntoView({ behavior, block: 'start' }); } }, getCurrentItem: () => currentIndexRef.current, }), [scrollBehavior, items.length]); const handleMediaLoad = react.useCallback((index) => { setLoadingStates(prev => ({ ...prev, [index]: false })); }, []); const handleMediaError = react.useCallback((index) => { setErrorStates(prev => ({ ...prev, [index]: true })); setLoadingStates(prev => ({ ...prev, [index]: false })); }, []); react.useEffect(() => { const observer = new IntersectionObserver(entries => { entries.forEach(entry => { const index = parseInt(entry.target.getAttribute('data-index') || '0', 10); const item = items[index]; if (entry.isIntersecting) { const video = entry.target.querySelector('video'); if (video) { video.play().catch(error => { if (onVideoErrorRef.current) { onVideoErrorRef.current(item, index, error); } else { console.error('Error playing video:', error); } }); } currentIndexRef.current = index; onCurrentItemChangeRef.current?.(index); onItemVisibleRef.current?.(item, index); } else { const video = entry.target.querySelector('video'); if (video) { video.pause(); } onItemHiddenRef.current?.(item, index); } }); }, { threshold, }); const mediaElements = containerRef.current?.querySelectorAll('[data-index]') || []; mediaElements.forEach(media => observer.observe(media)); return () => { observer.disconnect(); }; }, [items, threshold]); const handleScroll = react.useCallback(() => { if (!containerRef.current || !onEndReached) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; const isNearEnd = scrollTop + clientHeight >= scrollHeight - endReachedThreshold; if (isNearEnd && !endReachedCalledRef.current) { endReachedCalledRef.current = true; onEndReached(); } else if (!isNearEnd) { // Reset the flag when user scrolls away from the end endReachedCalledRef.current = false; } }, [onEndReached, endReachedThreshold]); const handleKeyDown = react.useCallback((e) => { if (!containerRef.current) return; const { scrollTop, clientHeight, scrollHeight } = containerRef.current; const scrollAmount = clientHeight; switch (e.key) { case ' ': { e.preventDefault(); const currentElement = containerRef.current.querySelector(`[data-index="${currentIndexRef.current}"]`); const video = currentElement?.querySelector('video'); if (video) { if (video.paused) { video.play().catch(() => { }); } else { video.pause(); } } break; } case 'ArrowDown': containerRef.current.scrollTo?.({ top: scrollTop + scrollAmount, behavior: scrollBehavior, }); break; case 'ArrowUp': containerRef.current.scrollTo?.({ top: scrollTop - scrollAmount, behavior: scrollBehavior, }); break; case 'Home': containerRef.current.scrollTo?.({ top: 0, behavior: scrollBehavior, }); break; case 'End': containerRef.current.scrollTo?.({ top: scrollHeight, behavior: scrollBehavior, }); break; } }, [scrollBehavior]); const defaultRenderItem = react.useCallback((item, index) => { const isLoading = loadingStates[index] ?? true; const hasError = errorStates[index] ?? false; return (jsxRuntime.jsxs("div", { "data-index": index, onClick: () => onItemClick?.(item, index), style: { height: '100vh', scrollSnapAlign: 'start', position: 'relative', cursor: onItemClick ? 'pointer' : 'default', }, role: "region", "aria-label": `Video ${index + 1}`, children: [isLoading && loadingComponent, hasError && errorComponent, jsxRuntime.jsx("video", { src: item.src, muted: item.muted ?? true, playsInline: item.playsInline ?? true, controls: item.controls ?? false, autoPlay: item.autoPlay ?? true, loop: item.loop ?? false, poster: item.poster, preload: item.preload ?? defaultPreload, onLoadedData: () => handleMediaLoad(index), onError: () => handleMediaError(index), style: { width: '100%', height: '100%', objectFit: 'cover', display: isLoading || hasError ? 'none' : 'block', } }), renderItemOverlay && renderItemOverlay(item, index)] }, item.id || index)); }, [ loadingStates, errorStates, loadingComponent, errorComponent, handleMediaLoad, handleMediaError, onItemClick, renderItemOverlay, defaultPreload, ]); const mediaElements = react.useMemo(() => items.map((item, index) => defaultRenderItem(item, index)), [items, defaultRenderItem]); return (jsxRuntime.jsx("div", { ref: containerRef, onScroll: handleScroll, onKeyDown: handleKeyDown, tabIndex: 0, role: "feed", "aria-label": "Vertical video feed", className: className, style: { height: '100vh', overflowY: 'scroll', scrollSnapType: 'y mandatory', outline: 'none', ...style, }, children: mediaElements })); }); VerticalFeed.displayName = 'VerticalFeed'; exports.VerticalFeed = VerticalFeed; //# sourceMappingURL=index.js.map