feat: quiz in videos

This commit is contained in:
Jannat Patel
2025-06-02 18:18:13 +05:30
parent 50e94b85aa
commit 7b7484332b
9 changed files with 109 additions and 50 deletions

View File

@@ -181,7 +181,16 @@
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue' import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
@@ -626,4 +635,8 @@ watch(userResource, () => {
const redirectToWebsite = () => { const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank') window.open('https://frappe.io/learning', '_blank')
} }
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script> </script>

View File

@@ -97,7 +97,7 @@ import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
} }
) )
} }
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script> </script>

View File

@@ -70,7 +70,7 @@
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils' import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue' import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue' import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue' import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next' import { MessageSquareText } from 'lucide-vue-next'
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
const openTopicModal = () => { const openTopicModal = () => {
showTopicModal.value = true showTopicModal.value = true
} }
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script> </script>

View File

@@ -10,10 +10,10 @@
<div class="text-base"> <div class="text-base">
<div class="flex items-end gap-4"> <div class="flex items-end gap-4">
<FormControl <FormControl
:label="__('Time in Video (minutes)')" :label="__('Time in Video (minutes:seconds)')"
v-model="quiz.time" v-model="quiz.time"
type="number" type="text"
placeholder="Time" placeholder="2:15"
class="flex-1" class="flex-1"
/> />
<Link <Link

View File

@@ -58,19 +58,30 @@
<div class="font-semibold text-lg text-ink-gray-9"> <div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }} {{ quiz.data.title }}
</div> </div>
<Button <div class="flex items-center justify-center space-x-2 mt-4">
<Button
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
"
variant="solid"
@click="startQuiz"
>
<span>
{{ inVideo ? __('Start the Quiz') : __('Start') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
<div
v-if=" v-if="
!quiz.data.max_attempts || quiz.data.max_attempts &&
attempts.data?.length < quiz.data.max_attempts attempts.data?.length >= quiz.data.max_attempts
" "
@click="startQuiz" class="leading-5 text-ink-gray-7"
class="mt-2"
> >
<span>
{{ __('Start') }}
</span>
</Button>
<div v-else class="leading-5 text-ink-gray-7">
{{ {{
__( __(
'You have already exceeded the maximum number of attempts allowed for this quiz.' 'You have already exceeded the maximum number of attempts allowed for this quiz.'
@@ -263,7 +274,7 @@
{{ __('Try Again') }} {{ __('Try Again') }}
</span> </span>
</Button> </Button>
<Button v-if="inVideo" @click="props.onSubmit()"> <Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }} {{ __('Resume Video') }}
</Button> </Button>
</div> </div>
@@ -316,7 +327,6 @@ let questions = reactive([])
const possibleAnswer = ref(null) const possibleAnswer = ref(null)
const timer = ref(0) const timer = ref(0)
let timerInterval = null let timerInterval = null
const router = useRouter()
const props = defineProps({ const props = defineProps({
quizName: { quizName: {
@@ -327,7 +337,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
onSubmit: { backToVideo: {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
@@ -627,11 +637,15 @@ const getInstructions = (question) => {
} }
const markLessonProgress = () => { const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') { let pathname = window.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', { call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName, course: pathname[3],
chapter_number: router.currentRoute.value.params.chapterNumber, chapter_number: lessonIndex[0],
lesson_number: router.currentRoute.value.params.lessonNumber, lesson_number: lessonIndex[1],
}) })
} }
} }

View File

@@ -1,11 +1,11 @@
<template> <template>
<div> <div>
<div <div
v-if="quizzes.length && !showQuiz" v-if="quizzes.length && !showQuiz && readOnly"
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5" class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
> >
{{ {{
__('This video has {0} {1}:').format( __('This video contains {0} {1}:').format(
quizzes.length, quizzes.length,
quizzes.length == 1 ? 'quiz' : 'quizzes' quizzes.length == 1 ? 'quiz' : 'quizzes'
) )
@@ -13,7 +13,7 @@
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1"> <div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
<span> {{ index + 1 }}. {{ quiz.quiz }} </span> <span> {{ index + 1 }}. {{ quiz.quiz }} </span>
{{ __('at {0} minutes').format(quiz.time) }} {{ __('at {0}').format(formatTimestamp(quiz.time)) }}
</div> </div>
</div> </div>
<div <div
@@ -102,7 +102,7 @@
v-if="showQuiz" v-if="showQuiz"
:quizName="currentQuiz" :quizName="currentQuiz"
:inVideo="true" :inVideo="true"
:onSubmit="resumeVideo" :backToVideo="resumeVideo"
/> />
<div v-if="!readOnly" @click="showQuizModal = true"> <div v-if="!readOnly" @click="showQuizModal = true">
<Button> <Button>
@@ -118,7 +118,7 @@
/> />
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui' import { Button } from 'frappe-ui'
import { formatSeconds } from '@/utils' import { formatSeconds } from '@/utils'
@@ -170,37 +170,45 @@ const updateCurrentTime = () => {
} }
videoRef.value.ontimeupdate = () => { videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value?.currentTime || currentTime.value currentTime.value = videoRef.value?.currentTime || currentTime.value
if (currentTime.value >= nextQuiz.value.time * 60) { if (currentTime.value >= nextQuiz.value.time) {
videoRef.value.pause() videoRef.value.pause()
playing.value = false playing.value = false
videoRef.value.onTimeupdate = null videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true showQuiz.value = true
updateNextQuiz()
} }
} }
}, 0) }, 0)
} }
const resumeVideo = () => { const resumeVideo = (restart = false) => {
showQuiz.value = false showQuiz.value = false
currentQuiz.value = null currentQuiz.value = null
updateCurrentTime() updateCurrentTime()
setTimeout(() => { setTimeout(() => {
videoRef.value.currentTime = currentTime.value videoRef.value.currentTime = restart ? 0 : currentTime.value
videoRef.value.play() videoRef.value.play()
playing.value = true playing.value = true
updateNextQuiz()
}, 0) }, 0)
} }
const updateNextQuiz = () => { const updateNextQuiz = () => {
if (!props.quizzes.length) return if (!props.quizzes.length) return
props.quizzes.forEach((quiz) => {
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
let time = quiz.time.split(':')
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
quiz.time = timeInSeconds
}
})
props.quizzes.sort((a, b) => a.time - b.time) props.quizzes.sort((a, b) => a.time - b.time)
const nextQuizIndex = props.quizzes.findIndex( const nextQuizIndex = props.quizzes.findIndex(
(quiz) => quiz.time * 60 > currentTime.value (quiz) => quiz.time > currentTime.value
) )
if (nextQuizIndex !== -1) { if (nextQuizIndex !== -1) {
nextQuiz.value = props.quizzes[nextQuizIndex] nextQuiz.value = props.quizzes[nextQuizIndex]
} else { } else {
@@ -209,22 +217,10 @@ const updateNextQuiz = () => {
} }
const fileURL = computed(() => { const fileURL = computed(() => {
if (isYoutube) {
let url = props.file
if (url.includes('watch?v=')) {
url = url.replace('watch?v=', 'embed/')
}
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
}
return props.file return props.file
}) })
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => { const playVideo = () => {
console.log(currentTime.value)
videoRef.value.play() videoRef.value.play()
playing.value = true playing.value = true
} }
@@ -235,7 +231,6 @@ const pauseVideo = () => {
} }
const togglePlay = () => { const togglePlay = () => {
console.log(currentTime.value)
if (playing.value) { if (playing.value) {
pauseVideo() pauseVideo()
} else { } else {
@@ -254,7 +249,6 @@ const toggleMute = () => {
const changeCurrentTime = () => { const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value videoRef.value.currentTime = currentTime.value
console.log('Current Time:', currentTime.value)
updateNextQuiz() updateNextQuiz()
} }
@@ -265,6 +259,13 @@ const toggleFullscreen = () => {
videoContainer.value.requestFullscreen() videoContainer.value.requestFullscreen()
} }
} }
const formatTimestamp = (seconds) => {
const date = new Date(seconds * 1000)
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const secs = String(date.getUTCSeconds()).padStart(2, '0')
return `${minutes}:${secs}`
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -303,6 +303,7 @@ import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
const user = inject('$user') const user = inject('$user')
const socket = inject('$socket')
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const allowDiscussions = ref(false) const allowDiscussions = ref(false)
@@ -335,6 +336,11 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() startTimer()
document.addEventListener('fullscreenchange', attachFullscreenEvent) document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
lessonProgress.value = data.progress
}
})
}) })
const attachFullscreenEvent = () => { const attachFullscreenEvent = () => {

View File

@@ -68,7 +68,7 @@ import {
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { computed, inject, ref, onMounted } from 'vue' import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
@@ -135,6 +135,10 @@ const markAllAsRead = createResource({
}, },
}) })
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let crumbs = [ let crumbs = [
{ {

View File

@@ -2,12 +2,13 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
import json
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress from lms.lms.utils import get_course_progress
from ...md import find_macros from ...md import find_macros
import json from frappe.realtime import get_website_room
class CourseLesson(Document): class CourseLesson(Document):
@@ -55,6 +56,7 @@ def save_progress(lesson, course):
) )
quiz_completed = get_quiz_progress(lesson) quiz_completed = get_quiz_progress(lesson)
print("quiz_completed", quiz_completed)
assignment_completed = get_assignment_progress(lesson) assignment_completed = get_assignment_progress(lesson)
if not already_completed and quiz_completed and assignment_completed: if not already_completed and quiz_completed and assignment_completed:
@@ -76,6 +78,13 @@ def save_progress(lesson, course):
enrollment.save() enrollment.save()
enrollment.run_method("on_change") enrollment.run_method("on_change")
frappe.publish_realtime(
event="update_lesson_progress",
room=get_website_room(),
message={"course": course, "lesson": lesson, "progress": progress},
after_commit=True,
)
return progress return progress
@@ -106,8 +115,10 @@ def get_quiz_progress(lesson):
macros = find_macros(lesson_details.body) macros = find_macros(lesson_details.body)
quizzes = [value for name, value in macros if name == "Quiz"] quizzes = [value for name, value in macros if name == "Quiz"]
print(quizzes)
for quiz in quizzes: for quiz in quizzes:
passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage") passing_percentage = frappe.db.get_value("LMS Quiz", quiz, "passing_percentage")
print(quiz, passing_percentage)
if not frappe.db.exists( if not frappe.db.exists(
"LMS Quiz Submission", "LMS Quiz Submission",
{ {