Implemented document progress tracking based on read time, got video progress tracking working with Youtube embeds

This commit is contained in:
austinkelsay 2024-09-19 17:33:22 -05:00
parent f7bbf93f95
commit 4585ed263c
8 changed files with 197 additions and 17 deletions

View File

@ -11,6 +11,7 @@ import { getTotalFromZaps } from "@/utils/lightning";
import dynamic from "next/dynamic";
import useWindowWidth from "@/hooks/useWindowWidth";
import appConfig from "@/config/appConfig";
import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson";
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
@ -22,10 +23,19 @@ const MDDisplay = dynamic(
const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
const [zapAmount, setZapAmount] = useState(0);
const [nAddress, setNAddress] = useState(null);
const [completed, setCompleted] = useState(false);
const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" });
const { returnImageProxy } = useImageProxy();
const windowWidth = useWindowWidth();
const isMobileView = windowWidth <= 768;
// todo implement real read time needs to be on form
const readTime = 30;
const { isCompleted, isTracking } = useTrackDocumentLesson({
lessonId: lesson?.d,
courseId: course?.d,
readTime: readTime,
});
useEffect(() => {
if (!zaps || zapsLoading || zapsError) return;
@ -45,6 +55,12 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
}
}, [lesson]);
useEffect(() => {
if (isCompleted) {
setCompleted(lesson.id);
}
}, [isCompleted, lesson.id, setCompleted]);
const renderContent = () => {
if (isPaid && decryptionPerformed) {
return <MDDisplay className='p-2 rounded-lg w-full' source={lesson.content} />;
@ -90,14 +106,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
)}
</div>
</div>
<p className='text-xl text-gray-200 mb-4 mt-4'>{lesson.summary && (
<div className='text-xl text-gray-200 mb-4 mt-4'>{lesson.summary && (
<div className="text-xl mt-4">
{lesson.summary.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
)}
</p>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<Image

View File

@ -292,11 +292,11 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
['summary', draft.summary],
['image', draft.image],
// todo populate this tag from the config
['i', 'youtube:plebdevs', 'V_fvmyJ91m0'],
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
...(draft?.additionalLinks ? draft.additionalLinks.filter(link => link !== 'https://plebdevs.com').map(link => ['r', link]) : []),
draft?.price ? null : ['i', 'youtube:plebdevs', 'V_fvmyJ91m0']
];
type = 'video';

View File

@ -38,17 +38,53 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
videoPlayed
});
useEffect(() => {
const handleYouTubeMessage = (event) => {
if (event.origin !== "https://www.youtube.com") return;
try {
const data = JSON.parse(event.data);
console.log('youtube data', data);
if (data.event === "onReady") {
event.source.postMessage('{"event":"listening"}', "https://www.youtube.com");
} else if (data.event === "infoDelivery" && data?.info && data?.info?.currentTime) {
setVideoPlayed(true);
setVideoDuration(data.info?.progressState?.duration);
event.source.postMessage('{"event":"command","func":"getDuration","args":""}', "https://www.youtube.com");
}
} catch (error) {
console.error("Error parsing YouTube message:", error);
}
};
window.addEventListener("message", handleYouTubeMessage);
return () => {
window.removeEventListener("message", handleYouTubeMessage);
};
}, []);
useEffect(() => {
if (videoDuration && videoPlayed) {
console.log('videoDuration and videoPlayed', videoDuration, videoPlayed);
}
}, [videoDuration, videoPlayed]);
useEffect(() => {
if (videoPlayed) {
console.log('videoPlayed', videoPlayed);
}
}, [videoPlayed]);
const checkDuration = useCallback(() => {
const videoElement = mdDisplayRef.current?.querySelector('video');
const youtubeIframe = mdDisplayRef.current?.querySelector('iframe[src*="youtube.com"]');
if (videoElement && videoElement.readyState >= 1) {
setVideoDuration(Math.round(videoElement.duration));
// Add event listener for play event
videoElement.addEventListener('play', () => {
setVideoPlayed(true);
});
} else if (videoElement) {
setTimeout(checkDuration, 100);
setVideoPlayed(true);
} else if (youtubeIframe) {
youtubeIframe.contentWindow.postMessage('{"event":"listening"}', '*');
}
}, []);
@ -78,6 +114,10 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
if (decryptionPerformed && isPaid) {
const timer = setTimeout(checkDuration, 500);
return () => clearTimeout(timer);
} else {
// For non-paid content, start checking after 3 seconds
const timer = setTimeout(checkDuration, 3000);
return () => clearTimeout(timer);
}
}, [decryptionPerformed, isPaid, checkDuration]);
@ -109,7 +149,11 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
</div>
);
} else if (lesson?.content) {
return <MDDisplay className='p-0 rounded-lg w-full' source={lesson.content} />;
return (
<div ref={mdDisplayRef}>
<MDDisplay className='p-0 rounded-lg w-full' source={lesson.content} />
</div>
);
}
return null;
}
@ -129,14 +173,14 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
)}
</div>
<div className='flex flex-row items-center justify-between w-full'>
<p className='text-xl mt-4 text-gray-200'>{lesson.summary && (
<div className='text-xl mt-4 text-gray-200'>{lesson.summary && (
<div className="text-xl mt-4">
{lesson.summary.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
)}
</p>
</div>
<ZapDisplay
zapAmount={zapAmount}
event={lesson}

View File

@ -12,6 +12,7 @@ import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
import 'primereact/resources/primereact.min.css';
// todo need to handle case where published video is being edited and not just draft
const VideoForm = ({ draft = null }) => {
const [title, setTitle] = useState(draft?.title || '');
const [summary, setSummary] = useState(draft?.summary || '');
@ -53,7 +54,7 @@ const VideoForm = ({ draft = null }) => {
// Check if it's a YouTube video
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
const videoId = videoUrl.split('v=')[1] || videoUrl.split('/').pop();
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}?enablejsapi=1" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
}
// Check if it's a Vimeo video
else if (videoUrl.includes('vimeo.com')) {

View File

@ -50,7 +50,7 @@ const EmbeddedVideoForm = ({ draft = null, onSave, isPaid }) => {
// Check if it's a YouTube video
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
const videoId = videoUrl.split('v=')[1] || videoUrl.split('/').pop();
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}?enablejsapi=1" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
}
// Check if it's a Vimeo video
else if (videoUrl.includes('vimeo.com')) {

View File

@ -0,0 +1,105 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import axios from 'axios';
const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => {
const [isCompleted, setIsCompleted] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [isTracking, setIsTracking] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const timerRef = useRef(null);
const { data: session } = useSession();
const completedRef = useRef(false);
useEffect(() => {
if (session?.user?.role?.admin) {
setIsAdmin(true);
setIsCompleted(true); // Automatically mark as completed for admins
}
}, [session]);
const checkOrCreateUserLesson = useCallback(async () => {
if (!session?.user) return false;
try {
const response = await axios.get(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`);
if (response.status === 200 && response?.data) {
if (response?.data?.completed) {
setIsCompleted(true);
completedRef.current = true;
return true;
} else {
return false;
}
} else if (response.status === 204) {
await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, {
resourceId: lessonId,
opened: true,
openedAt: new Date().toISOString(),
});
return false;
} else {
console.error('Error checking or creating UserLesson:', response.statusText);
return false;
}
} catch (error) {
console.error('Error checking or creating UserLesson:', error);
return false;
}
}, [session, lessonId, courseId]);
const markLessonAsCompleted = useCallback(async () => {
if (!session?.user || completedRef.current) return;
completedRef.current = true;
try {
const response = await axios.put(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`, {
completed: true,
completedAt: new Date().toISOString(),
});
if (response.status === 200) {
setIsCompleted(true);
setIsTracking(false);
} else {
console.error('Failed to mark lesson as completed:', response.statusText);
}
} catch (error) {
console.error('Error marking lesson as completed:', error);
}
}, [lessonId, courseId, session]);
useEffect(() => {
const initializeTracking = async () => {
if (isAdmin) return; // Skip tracking for admin users
const alreadyCompleted = await checkOrCreateUserLesson();
if (!alreadyCompleted && !completedRef.current) {
setIsTracking(true);
timerRef.current = setInterval(() => {
setTimeSpent(prevTime => prevTime + 1);
}, 1000);
}
};
initializeTracking();
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [lessonId, checkOrCreateUserLesson, isAdmin]);
useEffect(() => {
if (isAdmin) return; // Skip tracking for admin users
// Mark lesson as completed after readTime seconds
if (timeSpent >= readTime && !completedRef.current) {
markLessonAsCompleted();
}
}, [timeSpent, markLessonAsCompleted, readTime, isAdmin]);
return { isCompleted, isTracking };
};
export default useTrackDocumentLesson;

View File

@ -6,10 +6,18 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) =
const [isCompleted, setIsCompleted] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [isTracking, setIsTracking] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const timerRef = useRef(null);
const { data: session } = useSession();
const completedRef = useRef(false);
useEffect(() => {
if (session?.user?.role?.admin) {
setIsAdmin(true);
setIsCompleted(true); // Automatically mark as completed for admins
}
}, [session]);
// Check if the lesson is already completed or create a new UserLesson record
const checkOrCreateUserLesson = useCallback(async () => {
if (!session?.user) return false;
@ -68,6 +76,9 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) =
useEffect(() => {
const initializeTracking = async () => {
console.log('initializeTracking', videoDuration, !completedRef.current, videoPlayed);
if (isAdmin) return; // Skip tracking for admin users
const alreadyCompleted = await checkOrCreateUserLesson();
// Case 3: Start tracking if the lesson is not completed, video duration is available, and video has been played
if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed) {
@ -87,14 +98,16 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) =
clearInterval(timerRef.current);
}
};
}, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed]);
}, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed, isAdmin]);
useEffect(() => {
if (isAdmin) return; // Skip tracking for admin users
// Case 4: Mark lesson as completed when 90% of the video is watched
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) {
markLessonAsCompleted();
}
}, [timeSpent, videoDuration, markLessonAsCompleted]);
}, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]);
return { isCompleted, isTracking };
};

View File

@ -106,6 +106,7 @@ export const authOptions = {
}
}
// todo this does not work on first login only the second time
if (user && appConfig.authorPubkeys.includes(user?.pubkey) && !user?.role) {
// create a new author role for this user
const role = await createRole({