Add video chapter/timeline navigation feature
Implement timeline chapters for webinar and bootcamp videos with click-to-jump functionality: **Components:** - VideoPlayerWithChapters: Plyr.io-based player with chapter support - TimelineChapters: Clickable chapter markers with active state - ChaptersEditor: Admin UI for managing video chapters **Features:** - YouTube videos: Clickable timestamps that jump to specific time - Embed videos: Static timeline display (non-clickable) - Real-time chapter tracking during playback - Admin-defined accent color for Plyr theme - Auto-hides timeline when no chapters configured **Database:** - Add chapters JSONB column to products table (webinars) - Add chapters JSONB column to bootcamp_lessons table - Create indexes for faster queries **Updated Pages:** - WebinarRecording: Two-column layout (video + timeline) - Bootcamp: Per-lesson chapter support - AdminProducts: Chapter editor for webinars - CurriculumEditor: Chapter editor for lessons 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
203
src/components/VideoPlayerWithChapters.tsx
Normal file
203
src/components/VideoPlayerWithChapters.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import Plyr from 'plyr';
|
||||
import 'plyr/dist/plyr.css';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number; // Time in seconds
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface VideoPlayerWithChaptersProps {
|
||||
videoUrl: string;
|
||||
embedCode?: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
accentColor?: string;
|
||||
onChapterChange?: (chapter: VideoChapter) => void;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoPlayerRef {
|
||||
jumpToTime: (time: number) => void;
|
||||
getCurrentTime: () => number;
|
||||
}
|
||||
|
||||
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
|
||||
videoUrl,
|
||||
embedCode,
|
||||
chapters = [],
|
||||
accentColor = '#f97316', // Default orange
|
||||
onChapterChange,
|
||||
onTimeUpdate,
|
||||
className = '',
|
||||
}, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Plyr | null>(null);
|
||||
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
|
||||
|
||||
// Detect if this is a YouTube URL
|
||||
const isYouTube = videoUrl && (
|
||||
videoUrl.includes('youtube.com') ||
|
||||
videoUrl.includes('youtu.be')
|
||||
);
|
||||
|
||||
// Get YouTube video ID
|
||||
const getYouTubeId = (url: string): string | null => {
|
||||
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s/]+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// Convert embed code to YouTube URL if possible
|
||||
const getYouTubeUrlFromEmbed = (embed: string): string | null => {
|
||||
const match = embed.match(/src=["'](?:https?:)?\/\/(?:www\.)?youtube\.com\/embed\/([^"'\s?]*)/);
|
||||
return match ? `https://www.youtube.com/watch?v=${match[1]}` : null;
|
||||
};
|
||||
|
||||
// Determine which video source to use
|
||||
const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl);
|
||||
const useEmbed = !isYouTube && embedCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
|
||||
// Initialize Plyr
|
||||
const player = new Plyr(wrapperRef.current, {
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
rel: 0,
|
||||
showinfo: 0,
|
||||
iv_load_policy: 3,
|
||||
modestbranding: 1,
|
||||
},
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'duration',
|
||||
'mute',
|
||||
'volume',
|
||||
'settings',
|
||||
'pip',
|
||||
'airplay',
|
||||
],
|
||||
settings: ['quality', 'speed'],
|
||||
keyboard: {
|
||||
global: true,
|
||||
},
|
||||
});
|
||||
|
||||
playerRef.current = player;
|
||||
|
||||
// Apply custom accent color
|
||||
if (accentColor) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.plyr--full-ui input[type=range] {
|
||||
color: ${accentColor};
|
||||
}
|
||||
.plyr__control--overlaid,
|
||||
.plyr__controls .plyr__control.plyr__tab-focus,
|
||||
.plyr__controls .plyr__control:hover,
|
||||
.plyr__controls .plyr__control[aria-expanded=true] {
|
||||
background: ${accentColor};
|
||||
}
|
||||
.plyr__progress__buffer {
|
||||
color: ${accentColor}40;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
player.destroy();
|
||||
};
|
||||
}, [accentColor]);
|
||||
|
||||
// Handle chapter tracking
|
||||
useEffect(() => {
|
||||
if (!playerRef.current || chapters.length === 0) return;
|
||||
|
||||
const player = playerRef.current;
|
||||
|
||||
const updateTime = () => {
|
||||
const currentTime = player.currentTime;
|
||||
|
||||
// Report time update to parent
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(currentTime);
|
||||
}
|
||||
|
||||
// Find current chapter
|
||||
let index = chapters.findIndex((chapter, i) => {
|
||||
const nextChapter = chapters[i + 1];
|
||||
return currentTime >= chapter.time && (!nextChapter || currentTime < nextChapter.time);
|
||||
});
|
||||
|
||||
// If before first chapter, no active chapter
|
||||
if (index === -1 && currentTime < chapters[0].time) {
|
||||
index = -1;
|
||||
}
|
||||
|
||||
if (index !== currentChapterIndex) {
|
||||
setCurrentChapterIndex(index);
|
||||
if (index >= 0 && onChapterChange) {
|
||||
onChapterChange(chapters[index]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
player.on('timeupdate', updateTime);
|
||||
|
||||
return () => {
|
||||
player.off('timeupdate', updateTime);
|
||||
};
|
||||
}, [chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
|
||||
|
||||
// Jump to specific time
|
||||
const jumpToTime = (time: number) => {
|
||||
if (playerRef.current && isYouTube) {
|
||||
playerRef.current.currentTime = time;
|
||||
playerRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentTime = () => {
|
||||
return playerRef.current ? playerRef.current.currentTime : 0;
|
||||
};
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
jumpToTime,
|
||||
getCurrentTime,
|
||||
}));
|
||||
|
||||
if (useEmbed) {
|
||||
// Custom embed (Adilo, Vimeo, etc.)
|
||||
return (
|
||||
<div
|
||||
className={`aspect-video rounded-lg overflow-hidden ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: embedCode }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={`plyr__video-embed ${className}`}>
|
||||
{youtubeId && (
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${youtubeId}?origin=${window.location.origin}&iv_load_policy=3&modestbranding=1&playsinline=1`}
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user