feat: video watch time tracking
This commit is contained in:
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@@ -102,6 +102,7 @@ declare module 'vue' {
|
||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||
}
|
||||
|
||||
143
frontend/src/components/Modals/VideoStatistics.vue
Normal file
143
frontend/src/components/Modals/VideoStatistics.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base">
|
||||
<TabButtons :buttons="tabs" v-model="currentTab" class="w-fit" />
|
||||
<div v-if="currentTab" class="mt-8">
|
||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||
<div class="space-y-5 border rounded-md p-2 pt-4">
|
||||
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||
<div class="px-4">
|
||||
{{ __('Member') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ __('Watch Time') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in currentTabData"
|
||||
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: row.member_username },
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Avatar
|
||||
:image="row.member_image"
|
||||
:label="row.member_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ row.member_name }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-6">
|
||||
{{ row.member }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-sm">
|
||||
{{ row.watch_time }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Average Watch Time (seconds)'),
|
||||
value: averageWatchTime,
|
||||
}"
|
||||
/>
|
||||
<VideoBlock :file="currentTab" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
createListResource,
|
||||
Dialog,
|
||||
NumberChart,
|
||||
TabButtons,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const currentTab = ref<string>('')
|
||||
|
||||
const props = defineProps<{
|
||||
lessonName: string
|
||||
lessonTitle: string
|
||||
}>()
|
||||
|
||||
const statistics = createListResource({
|
||||
doctype: 'LMS Video Watch Duration',
|
||||
filters: {
|
||||
lesson: props.lessonName,
|
||||
},
|
||||
fields: [
|
||||
'name',
|
||||
'member',
|
||||
'member_name',
|
||||
'member_image',
|
||||
'member_username',
|
||||
'source',
|
||||
'watch_time',
|
||||
],
|
||||
auto: true,
|
||||
cache: ['videoStatistics', props.lessonName],
|
||||
onSuccess() {
|
||||
currentTab.value = Object.keys(statisticsData.value)[0]
|
||||
},
|
||||
})
|
||||
|
||||
const statisticsData = computed(() => {
|
||||
const grouped = <Record<string, any[]>>{}
|
||||
statistics.data.forEach((item: { source: string }) => {
|
||||
if (!grouped[item.source]) {
|
||||
grouped[item.source] = []
|
||||
}
|
||||
grouped[item.source].push(item)
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
const averageWatchTime = computed(() => {
|
||||
let totalWatchTime = 0
|
||||
|
||||
currentTabData.value.forEach((item: { watch_time: string }) => {
|
||||
totalWatchTime += parseFloat(item.watch_time)
|
||||
})
|
||||
|
||||
return totalWatchTime / currentTabData.value.length
|
||||
})
|
||||
|
||||
const currentTabData = computed(() => {
|
||||
return statisticsData.value[currentTab.value] || []
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
return Object.keys(statisticsData.value).map((source, index) => ({
|
||||
label: __(`Video ${index + 1}`),
|
||||
value: source,
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
@@ -27,9 +27,9 @@
|
||||
oncontextmenu="return false"
|
||||
class="rounded-md border border-gray-100 cursor-pointer"
|
||||
ref="videoRef"
|
||||
>
|
||||
<source :src="fileURL" :type="type" />
|
||||
</video>
|
||||
:src="fileURL"
|
||||
:type="type"
|
||||
></video>
|
||||
<div
|
||||
v-if="!playing"
|
||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||
@@ -181,7 +181,7 @@ const props = defineProps({
|
||||
default: 'video/mp4',
|
||||
},
|
||||
readOnly: {
|
||||
type: String,
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
quizzes: {
|
||||
@@ -190,6 +190,7 @@ const props = defineProps({
|
||||
},
|
||||
saveQuizzes: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||
<template #prefix>
|
||||
<TrendingUp class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Video Statistics') }}
|
||||
</Button>
|
||||
<CertificationLinks :courseName="courseName" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -100,26 +106,15 @@
|
||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="lesson.data.prev"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.prev.split('.')[0],
|
||||
lessonNumber: lesson.data.prev.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||
<template #prefix>
|
||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Previous') }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<router-link
|
||||
v-if="allowEdit()"
|
||||
:to="{
|
||||
@@ -135,26 +130,16 @@
|
||||
{{ __('Edit') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="lesson.data.next"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.data.next.split('.')[0],
|
||||
lessonNumber: lesson.data.next.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||
<template #suffix>
|
||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Next') }}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<router-link
|
||||
v-else
|
||||
:to="{
|
||||
@@ -262,13 +247,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VideoStatistics
|
||||
v-model="showStatsDialog"
|
||||
:lessonName="lesson.data?.name"
|
||||
:lessonTitle="lesson.data?.title"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Tooltip,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
@@ -292,6 +283,7 @@ import {
|
||||
Focus,
|
||||
Info,
|
||||
MessageCircleQuestion,
|
||||
TrendingUp,
|
||||
} from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, enablePlyr } from '@/utils'
|
||||
@@ -302,6 +294,7 @@ import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const socket = inject('$socket')
|
||||
@@ -313,6 +306,7 @@ const instructorEditor = ref(null)
|
||||
const lessonProgress = ref(0)
|
||||
const lessonContainer = ref(null)
|
||||
const zenModeEnabled = ref(false)
|
||||
const showStatsDialog = ref(false)
|
||||
const hasQuiz = ref(false)
|
||||
const discussionsContainer = ref(null)
|
||||
const timer = ref(0)
|
||||
@@ -337,7 +331,6 @@ const props = defineProps({
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
console.log(sidebarStore.isSidebarCollapsed)
|
||||
sidebarStore.isSidebarCollapsed = true
|
||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||
socket.on('update_lesson_progress', (data) => {
|
||||
@@ -362,6 +355,7 @@ const attachFullscreenEvent = () => {
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||
sidebarStore.isSidebarCollapsed = false
|
||||
trackVideoWatchDuration()
|
||||
})
|
||||
|
||||
const lesson = createResource({
|
||||
@@ -457,6 +451,23 @@ const breadcrumbs = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const switchLesson = (direction) => {
|
||||
trackVideoWatchDuration()
|
||||
let lessonIndex =
|
||||
direction === 'prev'
|
||||
? lesson.data.prev.split('.')
|
||||
: lesson.data.next.split('.')
|
||||
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: props.courseName,
|
||||
chapterNumber: lessonIndex[0],
|
||||
lessonNumber: lessonIndex[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||
(
|
||||
@@ -464,29 +475,74 @@ watch(
|
||||
[oldChapterNumber, oldLessonNumber]
|
||||
) => {
|
||||
if (newChapterNumber || newLessonNumber) {
|
||||
editor.value = null
|
||||
instructorEditor.value = null
|
||||
allowDiscussions.value = false
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
resetLessonState(newChapterNumber, newLessonNumber)
|
||||
startTimer()
|
||||
enablePlyr()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const resetLessonState = (newChapterNumber, newLessonNumber) => {
|
||||
editor.value = null
|
||||
instructorEditor.value = null
|
||||
allowDiscussions.value = false
|
||||
lesson.submit({
|
||||
chapter: newChapterNumber,
|
||||
lesson: newLessonNumber,
|
||||
})
|
||||
clearInterval(timerInterval)
|
||||
timer.value = 0
|
||||
}
|
||||
|
||||
const trackVideoWatchDuration = () => {
|
||||
const videoDetails = []
|
||||
const videos = document.querySelectorAll('video')
|
||||
if (videos.length > 0 && lesson.data.membership) {
|
||||
videos.forEach((video) => {
|
||||
videoDetails.push({
|
||||
source: video.src,
|
||||
watch_time: video.currentTime,
|
||||
})
|
||||
})
|
||||
call('lms.lms.api.track_video_watch_duration', {
|
||||
lesson: lesson.data.name,
|
||||
videos: videoDetails,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => lesson.data,
|
||||
(data) => {
|
||||
setupLesson(data)
|
||||
enablePlyr()
|
||||
updateVideoWatchDuration()
|
||||
}
|
||||
)
|
||||
|
||||
const updateVideoWatchDuration = () => {
|
||||
setTimeout(() => {
|
||||
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
||||
lesson.data.videos.forEach((video) => {
|
||||
const videos = document.querySelectorAll('video')
|
||||
if (videos.length > 0) {
|
||||
videos.forEach((vid) => {
|
||||
if (vid.src === video.source) {
|
||||
if (vid.readyState >= 1) {
|
||||
vid.currentTime = video.watch_time
|
||||
} else {
|
||||
vid.addEventListener('loadedmetadata', () => {
|
||||
vid.currentTime = video.watch_time
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
const startTimer = () => {
|
||||
timerInterval = setInterval(() => {
|
||||
timer.value++
|
||||
@@ -553,6 +609,15 @@ const enrollStudent = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const canSeeStats = () => {
|
||||
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const showVideoStats = () => {
|
||||
showStatsDialog.value = true
|
||||
}
|
||||
|
||||
const canGoZen = () => {
|
||||
if (
|
||||
user.data?.is_moderator ||
|
||||
|
||||
Reference in New Issue
Block a user