diff --git a/package.json b/package.json index 6f5c62145..1fa53184c 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "framer-motion": "^11.3.28", "fuse.js": "^7.0.0", "hammerjs": "^2.0.8", + "idb": "^8.0.3", "ioredis": "^5.4.1", "jose": "^5.2.2", "katex": "^0.16.11", diff --git a/src/components/VideoPlayer2.tsx b/src/components/VideoPlayer2.tsx index 6e317c1ca..c8e61d26e 100644 --- a/src/components/VideoPlayer2.tsx +++ b/src/components/VideoPlayer2.tsx @@ -18,6 +18,7 @@ import { toast } from 'sonner'; import { createRoot } from 'react-dom/client'; import { PictureInPicture2 } from 'lucide-react'; import { AppxVideoPlayer } from './AppxVideoPlayer'; +import { getVideoFromIndexedDB, decryptBlob } from '@/lib/offlineVideo'; // todo correct types interface VideoPlayerProps { @@ -59,8 +60,9 @@ export const VideoPlayer: FunctionComponent = ({ const videoRef = useRef(null); const playerRef = useRef(null); const [player, setPlayer] = useState(null); + const [offlineUrl, setOfflineUrl] = useState(null); const searchParams = useSearchParams(); - const vidUrl = options.sources[0].src; + const vidUrl = offlineUrl || options.sources[0].src; const togglePictureInPicture = async () => { try { @@ -591,125 +593,40 @@ export const VideoPlayer: FunctionComponent = ({ return; } const currentTime = player.currentTime(); - if (currentTime <= 20) { - return; + if (contentId) { + await fetch('/api/course/videoProgress', { + method: 'POST', + body: JSON.stringify({ + contentId, + progress: currentTime, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); } - await fetch('/api/course/videoProgress', { - body: JSON.stringify({ - currentTimestamp: currentTime, - contentId, - }), - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }); }, - Math.ceil((100 * 1000) / player.playbackRate()), + 1000, ); }; - const handleVideoEnded = (interval: number) => { - handleMarkAsCompleted(true, contentId); - window.clearInterval(interval); - onVideoEnd(); - }; - - player.on('play', handleVideoProgress); - player.on('ended', () => handleVideoEnded(interval)); + handleVideoProgress(); return () => { - window.clearInterval(interval); + if (interval) { + clearInterval(interval); + } }; }, [player, contentId]); useEffect(() => { - if (!playerRef.current && videoRef.current) { - const videoElement = document.createElement('video-js'); - videoElement.classList.add('vjs-big-play-centered'); - videoRef.current.appendChild(videoElement); - const player: any = (playerRef.current = videojs( - videoElement, - { - ...options, - playbackRates: [0.5, 1, 1.25, 1.5, 1.75, 2], - }, - () => { - player.mobileUi(); // mobile ui #https://github.com/mister-ben/videojs-mobile-ui - player.eme(); // Initialize EME - setupZoomFeatures(player); - player.seekButtons({ - forward: 15, - back: 15, - }); - - player.qualitySelector = setQuality; - const qualitySelector = player.controlBar.addChild( - 'QualitySelectorControllBar', - ); - const controlBar = player.getChild('controlBar'); - const fullscreenToggle = controlBar.getChild('fullscreenToggle'); - - controlBar - .el() - .insertBefore(qualitySelector.el(), fullscreenToggle.el()); - - const pipButton = createPipButton(player); - controlBar.el().insertBefore(pipButton.el(), fullscreenToggle.el()); - - setPlayer(player); - if (options.isComposite) { - player.spriteThumbnails({ - interval: options.delta, - url: options.thumbnail.secure_url, - width: options.width, - height: options.height, - }); - } - player.on('loadedmetadata', () => { - if (onReady) { - onReady(player); - } - }); - // Focus the video player when toggling fullscreen - player.on('fullscreenchange', () => { - videoElement.focus(); - }); - }, - )); - - if ( - options.sources && - options.sources[0].type.includes('application/dash+xml') - ) { - player.src(options.sources[0]); - } - } - }, [options, onReady]); - - useEffect(() => { - if (player) { - const currentTime = player.currentTime(); - player.src(options.sources[0]); - player.currentTime(currentTime); - } - }, [options.sources[0]]); - - useEffect(() => { - const player = playerRef.current; - return () => { - if (player && !player.isDisposed()) { - player.dispose(); - playerRef.current = null; + (async () => { + const offline = await getVideoFromIndexedDB(contentId); + if (offline) { + const blob = await decryptBlob(offline.encrypted, offline.iv); + const url = URL.createObjectURL(blob); + setOfflineUrl(url); } - }; - }, []); - - useEffect(() => { - const t = searchParams.get('timestamp'); - - if (player && t) { - player.currentTime(parseInt(t, 10)); - } - }, [searchParams, player]); + })(); + }, [contentId]); const isYoutubeUrl = (url: string) => { const regex = /^https:\/\/www\.youtube\.com\/embed\/[a-zA-Z0-9_-]+/; @@ -730,5 +647,3 @@ export const VideoPlayer: FunctionComponent = ({ ); }; - -export default VideoPlayer; diff --git a/src/components/admin/ContentRendererClient.tsx b/src/components/admin/ContentRendererClient.tsx index f73c0422e..34a16b1b7 100644 --- a/src/components/admin/ContentRendererClient.tsx +++ b/src/components/admin/ContentRendererClient.tsx @@ -6,6 +6,7 @@ import { ChevronDown, ChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; import { Button } from '../ui/button'; import Link from 'next/link'; +import { encryptBlob, saveVideoToIndexedDB } from '@/lib/offlineVideo'; export const ContentRendererClient = ({ metadata, @@ -73,6 +74,25 @@ export const ContentRendererClient = ({ setShowChapters((prev) => !prev); }; + // Download, encrypt, and store video for offline use + const handleDownloadOffline = async () => { + try { + const videoUrl = metadata?.[quality || '1080'] || ''; + if (!videoUrl) { + alert('No video URL found for this quality.'); + return; + } + const response = await fetch(videoUrl); + if (!response.ok) throw new Error('Failed to fetch video'); + const blob = await response.blob(); + const { data, iv } = await encryptBlob(blob); + await saveVideoToIndexedDB(content.id, data, iv); + alert('Video saved for offline viewing!'); + } catch (err) { + alert('Failed to save video for offline use.'); + } + }; + return (
@@ -109,11 +129,22 @@ export const ContentRendererClient = ({

{content.title}

- {metadata.slides ? ( - - - - ) : null} +
+ {metadata.slides ? ( + + + + ) : null} + {content.type === 'video' && ( + + )} +
{!showChapters && metadata.segments?.length > 0 && ( diff --git a/src/lib/offlineVideo.ts b/src/lib/offlineVideo.ts new file mode 100644 index 000000000..24b1eaae6 --- /dev/null +++ b/src/lib/offlineVideo.ts @@ -0,0 +1,52 @@ +import { openDB } from 'idb'; + +const DB_NAME = 'offline-videos'; +const STORE_NAME = 'videos'; + +// Generate a crypto key for encryption/decryption (per user/session) +async function getKey(): Promise { + const keyMaterial = await window.crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + return keyMaterial; +} + +export async function encryptBlob(blob: Blob): Promise<{ data: ArrayBuffer; iv: Uint8Array }> { + const key = await getKey(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const data = await blob.arrayBuffer(); + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + data + ); + return { data: encrypted, iv }; +} + +export async function decryptBlob(encrypted: ArrayBuffer, iv: Uint8Array): Promise { + const key = await getKey(); + const decrypted = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted + ); + return new Blob([decrypted]); +} + +export async function saveVideoToIndexedDB(contentId: number, encrypted: ArrayBuffer, iv: Uint8Array) { + const db = await openDB(DB_NAME, 1, { + upgrade(db) { + db.createObjectStore(STORE_NAME); + }, + }); + await db.put(STORE_NAME, { encrypted, iv: Array.from(iv) }, contentId); +} + +export async function getVideoFromIndexedDB(contentId: number): Promise<{ encrypted: ArrayBuffer; iv: Uint8Array } | null> { + const db = await openDB(DB_NAME, 1); + const result = await db.get(STORE_NAME, contentId); + if (!result) return null; + return { encrypted: result.encrypted, iv: new Uint8Array(result.iv) }; +}