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 CollapseSidebar from '@/components/Icons/CollapseSidebar.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 { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session'
@@ -626,4 +635,8 @@ watch(userResource, () => {
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script>

View File

@@ -97,7 +97,7 @@ import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue'
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 newReply = ref('')
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
}
)
}
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script>

View File

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

View File

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

View File

@@ -58,19 +58,30 @@
<div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }}
</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="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
quiz.data.max_attempts &&
attempts.data?.length >= quiz.data.max_attempts
"
@click="startQuiz"
class="mt-2"
class="leading-5 text-ink-gray-7"
>
<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.'
@@ -263,7 +274,7 @@
{{ __('Try Again') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.onSubmit()">
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
@@ -316,7 +327,6 @@ let questions = reactive([])
const possibleAnswer = ref(null)
const timer = ref(0)
let timerInterval = null
const router = useRouter()
const props = defineProps({
quizName: {
@@ -327,7 +337,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
onSubmit: {
backToVideo: {
type: Function,
default: () => {},
},
@@ -627,11 +637,15 @@ const getInstructions = (question) => {
}
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', {
course: router.currentRoute.value.params.courseName,
chapter_number: router.currentRoute.value.params.chapterNumber,
lesson_number: router.currentRoute.value.params.lessonNumber,
course: pathname[3],
chapter_number: lessonIndex[0],
lesson_number: lessonIndex[1],
})
}
}

View File

@@ -1,11 +1,11 @@
<template>
<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"
>
{{
__('This video has {0} {1}:').format(
__('This video contains {0} {1}:').format(
quizzes.length,
quizzes.length == 1 ? 'quiz' : 'quizzes'
)
@@ -13,7 +13,7 @@
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
{{ __('at {0} minutes').format(quiz.time) }}
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
</div>
</div>
<div
@@ -102,7 +102,7 @@
v-if="showQuiz"
:quizName="currentQuiz"
:inVideo="true"
:onSubmit="resumeVideo"
:backToVideo="resumeVideo"
/>
<div v-if="!readOnly" @click="showQuizModal = true">
<Button>
@@ -118,7 +118,7 @@
/>
</template>
<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 { Button } from 'frappe-ui'
import { formatSeconds } from '@/utils'
@@ -170,37 +170,45 @@ const updateCurrentTime = () => {
}
videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value?.currentTime || currentTime.value
if (currentTime.value >= nextQuiz.value.time * 60) {
if (currentTime.value >= nextQuiz.value.time) {
videoRef.value.pause()
playing.value = false
videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true
updateNextQuiz()
}
}
}, 0)
}
const resumeVideo = () => {
const resumeVideo = (restart = false) => {
showQuiz.value = false
currentQuiz.value = null
updateCurrentTime()
setTimeout(() => {
videoRef.value.currentTime = currentTime.value
videoRef.value.currentTime = restart ? 0 : currentTime.value
videoRef.value.play()
playing.value = true
updateNextQuiz()
}, 0)
}
const updateNextQuiz = () => {
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)
const nextQuizIndex = props.quizzes.findIndex(
(quiz) => quiz.time * 60 > currentTime.value
(quiz) => quiz.time > currentTime.value
)
if (nextQuizIndex !== -1) {
nextQuiz.value = props.quizzes[nextQuizIndex]
} else {
@@ -209,22 +217,10 @@ const updateNextQuiz = () => {
}
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
})
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => {
console.log(currentTime.value)
videoRef.value.play()
playing.value = true
}
@@ -235,7 +231,6 @@ const pauseVideo = () => {
}
const togglePlay = () => {
console.log(currentTime.value)
if (playing.value) {
pauseVideo()
} else {
@@ -254,7 +249,6 @@ const toggleMute = () => {
const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value
console.log('Current Time:', currentTime.value)
updateNextQuiz()
}
@@ -265,6 +259,13 @@ const toggleFullscreen = () => {
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>
<style scoped>

View File

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

View File

@@ -68,7 +68,7 @@ import {
usePageMeta,
} from 'frappe-ui'
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 { X } from 'lucide-vue-next'
@@ -135,6 +135,10 @@ const markAllAsRead = createResource({
},
})
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
const breadcrumbs = computed(() => {
let crumbs = [
{

View File

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