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