UNPKG

unified-video-framework

Version:

Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more

309 lines (254 loc) 8.9 kB
/** * React hook for video chapters and skip functionality */ import { useState, useEffect, useCallback, useRef } from 'react'; import { VideoSegment, VideoChapters, ChapterConfig, ChapterEvents, SegmentType } from '../../chapters/types/ChapterTypes'; export interface UseChaptersOptions { videoElement?: HTMLVideoElement | null; chapters?: VideoChapters; config?: ChapterConfig; onSegmentEntered?: (segment: VideoSegment) => void; onSegmentSkipped?: (fromSegment: VideoSegment, toSegment?: VideoSegment) => void; onSkipButtonShown?: (segment: VideoSegment) => void; onSkipButtonHidden?: (segment: VideoSegment) => void; } export interface UseChaptersResult { // State currentSegment: VideoSegment | null; chapters: VideoChapters | null; isSkipButtonVisible: boolean; // Actions loadChapters: (chapters: VideoChapters) => Promise<void>; skipToSegment: (segmentId: string) => void; skipCurrentSegment: () => void; // Queries getSegmentsByType: (type: SegmentType) => VideoSegment[]; hasSegmentType: (type: SegmentType) => boolean; getChapterMarkers: () => Array<{ position: number; segment: VideoSegment; color: string; }>; // Utils formatTime: (seconds: number) => string; isInSegment: (segmentId: string) => boolean; } export function useChapters(options: UseChaptersOptions = {}): UseChaptersResult { const { videoElement, chapters: initialChapters, config = { enabled: true }, onSegmentEntered, onSegmentSkipped, onSkipButtonShown, onSkipButtonHidden } = options; // State const [currentSegment, setCurrentSegment] = useState<VideoSegment | null>(null); const [chapters, setChapters] = useState<VideoChapters | null>(initialChapters || null); const [isSkipButtonVisible, setIsSkipButtonVisible] = useState(false); // Refs const timeUpdateHandlerRef = useRef<(() => void) | null>(null); const previousSegmentRef = useRef<VideoSegment | null>(null); /** * Get current segment at given time */ const getCurrentSegment = useCallback((currentTime: number): VideoSegment | null => { if (!chapters) return null; return chapters.segments.find(segment => currentTime >= segment.startTime && currentTime < segment.endTime ) || null; }, [chapters]); /** * Handle time update */ const handleTimeUpdate = useCallback(() => { if (!videoElement || !chapters) return; const newSegment = getCurrentSegment(videoElement.currentTime); if (newSegment !== currentSegment) { // Segment changed if (currentSegment) { // Exiting current segment setIsSkipButtonVisible(false); onSkipButtonHidden?.(currentSegment); } previousSegmentRef.current = currentSegment; setCurrentSegment(newSegment); if (newSegment) { // Entering new segment onSegmentEntered?.(newSegment); // Show skip button for skippable segments if (shouldShowSkipButton(newSegment)) { setIsSkipButtonVisible(true); onSkipButtonShown?.(newSegment); } } } }, [videoElement, chapters, currentSegment, onSegmentEntered, onSkipButtonShown, onSkipButtonHidden, getCurrentSegment]); /** * Check if segment should show skip button */ const shouldShowSkipButton = useCallback((segment: VideoSegment): boolean => { if (!config.userPreferences?.showSkipButtons) { return false; } // Don't show for content segments by default if (segment.type === 'content') { return segment.showSkipButton === true; } // Show for other segment types unless explicitly disabled return segment.showSkipButton !== false; }, [config]); /** * Set up time update listener */ useEffect(() => { if (!videoElement) return; const handler = () => handleTimeUpdate(); timeUpdateHandlerRef.current = handler; videoElement.addEventListener('timeupdate', handler); return () => { videoElement.removeEventListener('timeupdate', handler); }; }, [videoElement, handleTimeUpdate]); /** * Load chapters */ const loadChapters = useCallback(async (newChapters: VideoChapters): Promise<void> => { // Validate chapters if (!newChapters.videoId || !newChapters.duration || !Array.isArray(newChapters.segments)) { throw new Error('Invalid chapters data'); } // Sort segments by start time const sortedChapters = { ...newChapters, segments: [...newChapters.segments].sort((a, b) => a.startTime - b.startTime) }; setChapters(sortedChapters); // Check current segment if video is playing if (videoElement) { const segment = getCurrentSegment(videoElement.currentTime); setCurrentSegment(segment); } }, [videoElement, getCurrentSegment]); /** * Skip to specific segment */ const skipToSegment = useCallback((segmentId: string) => { if (!chapters || !videoElement) return; const segment = chapters.segments.find(s => s.id === segmentId); if (!segment) return; const fromSegment = currentSegment; // Emit skip event if (fromSegment) { onSegmentSkipped?.(fromSegment, segment); } // Seek to segment start videoElement.currentTime = segment.startTime; }, [chapters, videoElement, currentSegment, onSegmentSkipped]); /** * Skip current segment */ const skipCurrentSegment = useCallback(() => { if (!currentSegment || !chapters || !videoElement) return; // Find next content segment const sortedSegments = [...chapters.segments].sort((a, b) => a.startTime - b.startTime); const currentIndex = sortedSegments.findIndex(s => s.id === currentSegment.id); if (currentIndex === -1) return; // Find next content segment let nextSegment: VideoSegment | undefined; for (let i = currentIndex + 1; i < sortedSegments.length; i++) { if (sortedSegments[i].type === 'content') { nextSegment = sortedSegments[i]; break; } } const targetTime = nextSegment ? nextSegment.startTime : currentSegment.endTime; // Emit skip event onSegmentSkipped?.(currentSegment, nextSegment); // Seek to target time videoElement.currentTime = targetTime; }, [currentSegment, chapters, videoElement, onSegmentSkipped]); /** * Get segments by type */ const getSegmentsByType = useCallback((type: SegmentType): VideoSegment[] => { if (!chapters) return []; return chapters.segments.filter(segment => segment.type === type); }, [chapters]); /** * Check if has segment type */ const hasSegmentType = useCallback((type: SegmentType): boolean => { return getSegmentsByType(type).length > 0; }, [getSegmentsByType]); /** * Get chapter markers for progress bar */ const getChapterMarkers = useCallback(() => { if (!chapters) return []; const segmentColors: Record<SegmentType, string> = { intro: '#ff5722', recap: '#ffc107', content: '#4caf50', credits: '#9c27b0', ad: '#f44336' }; return chapters.segments .filter(segment => segment.type !== 'content') .map(segment => ({ position: (segment.startTime / chapters.duration) * 100, segment, color: segmentColors[segment.type] })); }, [chapters]); /** * Format time as MM:SS or HH:MM:SS */ const formatTime = useCallback((seconds: number): string => { if (!seconds || isNaN(seconds)) return '00:00'; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } else { return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } }, []); /** * Check if currently in specific segment */ const isInSegment = useCallback((segmentId: string): boolean => { return currentSegment?.id === segmentId; }, [currentSegment]); // Load initial chapters useEffect(() => { if (initialChapters) { loadChapters(initialChapters); } }, [initialChapters, loadChapters]); return { // State currentSegment, chapters, isSkipButtonVisible, // Actions loadChapters, skipToSegment, skipCurrentSegment, // Queries getSegmentsByType, hasSegmentType, getChapterMarkers, // Utils formatTime, isInSegment }; }