Merge pull request #1554 from pateljannat/quiz-in-video
feat: show quiz in between videos
This commit is contained in:
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -81,6 +81,7 @@ declare module 'vue' {
|
|||||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||||
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
||||||
|
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
||||||
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
||||||
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add quiz to this video'),
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Time in Video')"
|
||||||
|
v-model="quiz.time"
|
||||||
|
type="text"
|
||||||
|
placeholder="2:15"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="quiz.quiz"
|
||||||
|
:label="__('Quiz')"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addQuiz()" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 mb-5">
|
||||||
|
<div class="font-medium mb-4">
|
||||||
|
{{ __('Quizzes in this video') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="allQuizzes.length"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="allQuizzes"
|
||||||
|
row-key="quiz"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in allQuizzes">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key as keyof Quiz]"
|
||||||
|
:align="column.align"
|
||||||
|
>
|
||||||
|
<div v-if="column.key == 'time'" class="leading-5 text-sm">
|
||||||
|
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5 text-sm">
|
||||||
|
{{ row[column.key as keyof Quiz] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeQuiz(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<div v-else class="text-ink-gray-5 italic text-xs">
|
||||||
|
{{ __('No quizzes added yet.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { formatTimestamp } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
type Quiz = {
|
||||||
|
time: string
|
||||||
|
quiz: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const allQuizzes = ref<Quiz[]>([])
|
||||||
|
const quiz = reactive<Quiz>({
|
||||||
|
time: '',
|
||||||
|
quiz: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizzes: {
|
||||||
|
type: Array as () => Quiz[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
quiz.time = `${getTimeInSeconds()}`
|
||||||
|
if (!isTimeValid() || !isFormComplete()) return
|
||||||
|
|
||||||
|
allQuizzes.value.push({
|
||||||
|
time: quiz.time,
|
||||||
|
quiz: quiz.quiz,
|
||||||
|
})
|
||||||
|
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
|
||||||
|
quiz.time = ''
|
||||||
|
quiz.quiz = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeInSeconds = () => {
|
||||||
|
if (quiz.time && !quiz.time.includes(':')) {
|
||||||
|
quiz.time = `${quiz.time}:00`
|
||||||
|
}
|
||||||
|
const timeParts = quiz.time.split(':')
|
||||||
|
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
|
||||||
|
|
||||||
|
return timeInSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTimeValid = () => {
|
||||||
|
if (parseInt(quiz.time) > props.duration) {
|
||||||
|
toast.error(__('Time in video exceeds the total duration of the video.'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormComplete = () => {
|
||||||
|
if (!quiz.time) {
|
||||||
|
toast.error(__('Please enter a valid timestamp'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quiz.quiz) {
|
||||||
|
toast.error(__('Please select a quiz'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQuiz = (selections: string, unselectAll: () => void) => {
|
||||||
|
Array.from(selections).forEach((selection) => {
|
||||||
|
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
|
||||||
|
if (index !== -1) {
|
||||||
|
allQuizzes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.quizzes,
|
||||||
|
(newQuizzes) => {
|
||||||
|
allQuizzes.value = newQuizzes
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'quiz',
|
||||||
|
label: __('Quiz'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
label: __('Time in Video (minutes)'),
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div
|
<div
|
||||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3"
|
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||||
>
|
>
|
||||||
|
<div v-if="inVideo">
|
||||||
|
{{ __('You will have to complete the quiz to continue the video') }}
|
||||||
|
</div>
|
||||||
<div class="leading-5">
|
<div class="leading-5">
|
||||||
{{
|
{{
|
||||||
__('This quiz consists of {0} questions.').format(questions.length)
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
@@ -55,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.'
|
||||||
@@ -247,18 +261,23 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div class="space-x-2">
|
||||||
@click="resetQuiz()"
|
<Button
|
||||||
class="mt-2"
|
@click="resetQuiz()"
|
||||||
v-if="
|
class="mt-2"
|
||||||
!quiz.data.max_attempts ||
|
v-if="
|
||||||
attempts?.data.length < quiz.data.max_attempts
|
!quiz.data.max_attempts ||
|
||||||
"
|
attempts?.data.length < quiz.data.max_attempts
|
||||||
>
|
"
|
||||||
<span>
|
>
|
||||||
{{ __('Try Again') }}
|
<span>
|
||||||
</span>
|
{{ __('Try Again') }}
|
||||||
</Button>
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -308,13 +327,20 @@ 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: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
inVideo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
backToVideo: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const quiz = createResource({
|
const quiz = createResource({
|
||||||
@@ -611,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],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="videoContainer" class="video-block relative group">
|
<div>
|
||||||
<video
|
|
||||||
@timeupdate="updateTime"
|
|
||||||
@ended="videoEnded"
|
|
||||||
@click="togglePlay"
|
|
||||||
oncontextmenu="return false"
|
|
||||||
class="rounded-md border border-gray-100 cursor-pointer"
|
|
||||||
ref="videoRef"
|
|
||||||
>
|
|
||||||
<source :src="fileURL" :type="type" />
|
|
||||||
</video>
|
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="quizzes.length && !showQuiz && readOnly"
|
||||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
|
||||||
@click="playVideo"
|
|
||||||
>
|
>
|
||||||
<div
|
{{
|
||||||
class="rounded-full p-4 pl-4.5"
|
__('This video contains {0} {1}:').format(
|
||||||
style="
|
quizzes.length,
|
||||||
background: radial-gradient(
|
quizzes.length == 1 ? 'quiz' : 'quizzes'
|
||||||
circle,
|
)
|
||||||
rgba(0, 0, 0, 0.3) 0%,
|
}}
|
||||||
rgba(0, 0, 0, 0.4) 50%
|
|
||||||
);
|
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||||
"
|
<span> {{ index + 1 }}. {{ quiz.quiz }} </span>
|
||||||
>
|
{{ __('at {0}').format(formatTimestamp(quiz.time)) }}
|
||||||
<Play />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
|
v-if="!showQuiz"
|
||||||
:class="{
|
ref="videoContainer"
|
||||||
'invisible group-hover:visible': playing,
|
class="video-block relative group"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<video
|
||||||
<template #icon>
|
@timeupdate="updateTime"
|
||||||
<Play
|
@ended="videoEnded"
|
||||||
v-if="!playing"
|
@click="togglePlay"
|
||||||
@click="playVideo"
|
oncontextmenu="return false"
|
||||||
class="size-4 text-ink-gray-9"
|
class="rounded-md border border-gray-100 cursor-pointer"
|
||||||
/>
|
ref="videoRef"
|
||||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
>
|
||||||
</template>
|
<source :src="fileURL" :type="type" />
|
||||||
</Button>
|
</video>
|
||||||
<Button variant="ghost" @click="toggleMute">
|
<div
|
||||||
<template #icon>
|
v-if="!playing"
|
||||||
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||||
<VolumeX v-else class="size-5 text-ink-white" />
|
@click="playVideo"
|
||||||
</template>
|
>
|
||||||
</Button>
|
<div
|
||||||
<input
|
class="rounded-full p-4 pl-4.5"
|
||||||
type="range"
|
style="
|
||||||
min="0"
|
background: radial-gradient(
|
||||||
:max="duration"
|
circle,
|
||||||
step="0.1"
|
rgba(0, 0, 0, 0.3) 0%,
|
||||||
v-model="currentTime"
|
rgba(0, 0, 0, 0.4) 50%
|
||||||
@input="changeCurrentTime"
|
);
|
||||||
class="duration-slider w-full h-1"
|
"
|
||||||
/>
|
>
|
||||||
<span class="text-sm font-semibold">
|
<Play />
|
||||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<Button variant="ghost" @click="toggleFullscreen">
|
<div
|
||||||
<template #icon>
|
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
|
||||||
<Maximize class="size-5 text-ink-white" />
|
:class="{
|
||||||
</template>
|
'invisible group-hover:visible': playing,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="hover:bg-transparent">
|
||||||
|
<template #icon>
|
||||||
|
<Play
|
||||||
|
v-if="!playing"
|
||||||
|
@click="playVideo"
|
||||||
|
class="size-4 text-ink-gray-9"
|
||||||
|
/>
|
||||||
|
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration"
|
||||||
|
step="0.1"
|
||||||
|
v-model="currentTime"
|
||||||
|
@input="changeCurrentTime"
|
||||||
|
class="duration-slider w-full h-1"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleMute"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
||||||
|
<VolumeX v-else class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Maximize class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Quiz
|
||||||
|
v-if="showQuiz"
|
||||||
|
:quizName="currentQuiz"
|
||||||
|
:inVideo="true"
|
||||||
|
:backToVideo="resumeVideo"
|
||||||
|
/>
|
||||||
|
<div v-if="!readOnly" @click="showQuizModal = true">
|
||||||
|
<Button>
|
||||||
|
{{ __('Add Quiz to Video') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<QuizInVideo
|
||||||
|
v-model="showQuizModal"
|
||||||
|
:quizzes="quizzes"
|
||||||
|
:saveQuizzes="saveQuizzes"
|
||||||
|
:duration="duration"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } 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, formatTimestamp } from '@/utils'
|
||||||
import Play from '@/components/Icons/Play.vue'
|
import Play from '@/components/Icons/Play.vue'
|
||||||
|
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||||
|
|
||||||
const videoRef = ref(null)
|
const videoRef = ref(null)
|
||||||
const videoContainer = ref(null)
|
const videoContainer = ref(null)
|
||||||
@@ -82,6 +131,10 @@ let playing = ref(false)
|
|||||||
let currentTime = ref(0)
|
let currentTime = ref(0)
|
||||||
let duration = ref(0)
|
let duration = ref(0)
|
||||||
let muted = ref(false)
|
let muted = ref(false)
|
||||||
|
const showQuizModal = ref(false)
|
||||||
|
const showQuiz = ref(false)
|
||||||
|
const currentQuiz = ref(null)
|
||||||
|
const nextQuiz = ref({})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@@ -92,34 +145,81 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'video/mp4',
|
default: 'video/mp4',
|
||||||
},
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: String,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
quizzes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
updateCurrentTime()
|
||||||
|
updateNextQuiz()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCurrentTime = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.value.onloadedmetadata = () => {
|
videoRef.value.onloadedmetadata = () => {
|
||||||
duration.value = videoRef.value.duration
|
duration.value = videoRef.value.duration
|
||||||
}
|
}
|
||||||
videoRef.value.ontimeupdate = () => {
|
videoRef.value.ontimeupdate = () => {
|
||||||
currentTime.value = videoRef.value.currentTime
|
currentTime.value = videoRef.value?.currentTime || currentTime.value
|
||||||
|
if (currentTime.value >= nextQuiz.value.time) {
|
||||||
|
videoRef.value.pause()
|
||||||
|
playing.value = false
|
||||||
|
videoRef.value.onTimeupdate = null
|
||||||
|
currentQuiz.value = nextQuiz.value.quiz
|
||||||
|
showQuiz.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const resumeVideo = (restart = false) => {
|
||||||
|
showQuiz.value = false
|
||||||
|
currentQuiz.value = null
|
||||||
|
updateCurrentTime()
|
||||||
|
setTimeout(() => {
|
||||||
|
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 > currentTime.value
|
||||||
|
)
|
||||||
|
if (nextQuizIndex !== -1) {
|
||||||
|
nextQuiz.value = props.quizzes[nextQuizIndex]
|
||||||
|
} else {
|
||||||
|
nextQuiz.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 = () => {
|
||||||
videoRef.value.play()
|
videoRef.value.play()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
@@ -149,12 +249,7 @@ const toggleMute = () => {
|
|||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
}
|
updateNextQuiz()
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
const minutes = Math.floor(time / 60)
|
|
||||||
const seconds = Math.floor(time % 60)
|
|
||||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ const addInstructorNotes = (data) => {
|
|||||||
const enableAutoSave = () => {
|
const enableAutoSave = () => {
|
||||||
autoSaveInterval = setInterval(() => {
|
autoSaveInterval = setInterval(() => {
|
||||||
saveLesson({ showSuccessMessage: false })
|
saveLesson({ showSuccessMessage: false })
|
||||||
}, 10000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,20 +28,21 @@ export function timeAgo(date) {
|
|||||||
export function formatTime(timeString) {
|
export function formatTime(timeString) {
|
||||||
if (!timeString) return ''
|
if (!timeString) return ''
|
||||||
const [hour, minute] = timeString.split(':').map(Number)
|
const [hour, minute] = timeString.split(':').map(Number)
|
||||||
|
|
||||||
// Create a Date object with dummy values for day, month, and year
|
|
||||||
const dummyDate = new Date(0, 0, 0, hour, minute)
|
const dummyDate = new Date(0, 0, 0, hour, minute)
|
||||||
|
|
||||||
// Use Intl.DateTimeFormat to format the time in 12-hour format
|
|
||||||
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
hour12: true,
|
hour12: true,
|
||||||
}).format(dummyDate)
|
}).format(dummyDate)
|
||||||
|
|
||||||
return formattedTime
|
return formattedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatSeconds = (time) => {
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatNumber(number) {
|
export function formatNumber(number) {
|
||||||
return number.toLocaleString('en-IN', {
|
return number.toLocaleString('en-IN', {
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
@@ -614,3 +615,10 @@ export const updateMetaInfo = (type, route, meta) => {
|
|||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export 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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,14 @@ export class Upload {
|
|||||||
if (this.isVideo(file.file_type)) {
|
if (this.isVideo(file.file_type)) {
|
||||||
const app = createApp(VideoBlock, {
|
const app = createApp(VideoBlock, {
|
||||||
file: file.file_url,
|
file: file.file_url,
|
||||||
|
readOnly: this.readOnly,
|
||||||
|
quizzes: file.quizzes || [],
|
||||||
|
saveQuizzes: (quizzes) => {
|
||||||
|
if (this.readOnly) return
|
||||||
|
this.data.quizzes = quizzes
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
app.use(translationPlugin)
|
||||||
app.mount(this.wrapper)
|
app.mount(this.wrapper)
|
||||||
return
|
return
|
||||||
} else if (this.isAudio(file.file_type)) {
|
} else if (this.isAudio(file.file_type)) {
|
||||||
@@ -93,6 +100,7 @@ export class Upload {
|
|||||||
return {
|
return {
|
||||||
file_url: this.data.file_url,
|
file_url: this.data.file_url,
|
||||||
file_type: this.data.file_type,
|
file_type: this.data.file_type,
|
||||||
|
quizzes: this.data.quizzes || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -76,6 +77,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
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +104,11 @@ def get_quiz_progress(lesson):
|
|||||||
for block in content.get("blocks"):
|
for block in content.get("blocks"):
|
||||||
if block.get("type") == "quiz":
|
if block.get("type") == "quiz":
|
||||||
quizzes.append(block.get("data").get("quiz"))
|
quizzes.append(block.get("data").get("quiz"))
|
||||||
|
if block.get("type") == "upload":
|
||||||
|
quizzes_in_video = block.get("data").get("quizzes")
|
||||||
|
if quizzes_in_video and len(quizzes_in_video) > 0:
|
||||||
|
for row in quizzes_in_video:
|
||||||
|
quizzes.append(row.get("quiz"))
|
||||||
|
|
||||||
elif lesson_details.body:
|
elif lesson_details.body:
|
||||||
macros = find_macros(lesson_details.body)
|
macros = find_macros(lesson_details.body)
|
||||||
|
|||||||
Reference in New Issue
Block a user