diff --git a/package-lock.json b/package-lock.json index e3548ef..df8f6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "lowlight": "^3.3.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "plyr-react": "^6.0.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -4254,6 +4255,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -4291,6 +4304,13 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/custom-event-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", + "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==", + "license": "MIT", + "peer": true + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -5373,6 +5393,13 @@ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, + "node_modules/loadjs": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", + "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==", + "license": "MIT", + "peer": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6217,6 +6244,36 @@ "node": ">= 6" } }, + "node_modules/plyr": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz", + "integrity": "sha512-0+iI5uw0WRvtKBpgPCkmQQv7ucHVQKTEo6UFJjgJ8cy/JZhy0dQqshHQVitHXV6l2O3MzhgnuvQ95VSkWcWeSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "core-js": "^3.45.1", + "custom-event-polyfill": "^1.0.7", + "loadjs": "^4.3.0", + "rangetouch": "^2.0.1", + "url-polyfill": "^1.1.13" + } + }, + "node_modules/plyr-react": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/plyr-react/-/plyr-react-6.0.0.tgz", + "integrity": "sha512-P8M+BuQoGrCd7m6K4QwwQlcSS1E26OeXuJTAmgLx11B9UqJrdc3Ka4TFwPwF3jul4EsVxSK9Zn1ME3DV8m9gdw==", + "license": "MIT", + "dependencies": { + "react-aptor": "^2.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "plyr": "^3.7.7", + "react": ">=16.8" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6630,6 +6687,13 @@ ], "license": "MIT" }, + "node_modules/rangetouch": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz", + "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==", + "license": "MIT", + "peer": true + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6642,6 +6706,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-aptor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-aptor/-/react-aptor-2.0.0.tgz", + "integrity": "sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/react-day-picker": { "version": "8.10.1", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", @@ -7453,6 +7534,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT", + "peer": true + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/package.json b/package.json index 8019f70..c846946 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lowlight": "^3.3.0", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "plyr-react": "^6.0.0", "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", diff --git a/src/components/TimelineChapters.tsx b/src/components/TimelineChapters.tsx new file mode 100644 index 0000000..b9117b8 --- /dev/null +++ b/src/components/TimelineChapters.tsx @@ -0,0 +1,122 @@ +import { Clock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; + +interface VideoChapter { + time: number; // Time in seconds + title: string; +} + +interface TimelineChaptersProps { + chapters: VideoChapter[]; + isYouTube?: boolean; + onChapterClick?: (time: number) => void; + currentTime?: number; // Current video playback time in seconds + accentColor?: string; +} + +export function TimelineChapters({ + chapters, + isYouTube = true, + onChapterClick, + currentTime = 0, + accentColor = '#f97316', +}: TimelineChaptersProps) { + // Format time in seconds to MM:SS or HH:MM:SS + const formatTime = (seconds: number): string => { + 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')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + + // Check if a chapter is currently active + const isChapterActive = (index: number): boolean => { + if (currentTime === 0) return false; + + const chapter = chapters[index]; + const nextChapter = chapters[index + 1]; + + return currentTime >= chapter.time && (!nextChapter || currentTime < nextChapter.time); + }; + + if (chapters.length === 0) { + return null; + } + + return ( + +
+
+ +

Timeline

+
+ +
+ {chapters.map((chapter, index) => { + const active = isChapterActive(index); + + return ( + + ); + })} +
+ + {!isYouTube && ( +

+ Timeline hanya tersedia untuk video YouTube +

+ )} +
+
+ ); +} diff --git a/src/components/VideoPlayerWithChapters.tsx b/src/components/VideoPlayerWithChapters.tsx new file mode 100644 index 0000000..8c21556 --- /dev/null +++ b/src/components/VideoPlayerWithChapters.tsx @@ -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(({ + videoUrl, + embedCode, + chapters = [], + accentColor = '#f97316', // Default orange + onChapterChange, + onTimeUpdate, + className = '', +}, ref) => { + const videoRef = useRef(null); + const wrapperRef = useRef(null); + const playerRef = useRef(null); + const [currentChapterIndex, setCurrentChapterIndex] = useState(-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 ( +
+ ); + } + + const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null; + + return ( +
+ {youtubeId && ( +