feat: video watch time tracking

This commit is contained in:
Jannat Patel
2025-06-30 19:56:07 +05:30
parent ce7fc35349
commit 5eaae06ceb
11 changed files with 507 additions and 55 deletions

View 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>

View File

@@ -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: () => {},
},
})

View File

@@ -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 ||