feat: track watch time for youtube and vimeo

This commit is contained in:
Jannat Patel
2025-07-01 16:55:55 +05:30
parent 22a2e57642
commit 2837ed16a7
4 changed files with 208 additions and 64 deletions

View File

@@ -8,7 +8,12 @@
>
<template #body-content>
<div class="text-base">
<TabButtons :buttons="tabs" v-model="currentTab" class="w-fit" />
<TabButtons
v-if="tabs.length > 1"
: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">
@@ -47,7 +52,7 @@
</div>
</div>
<div class="text-center text-sm">
{{ row.watch_time }}
{{ parseFloat(row.watch_time).toFixed(2) }}
</div>
</div>
</router-link>
@@ -61,7 +66,10 @@
value: averageWatchTime,
}"
/>
<VideoBlock :file="currentTab" />
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
</div>
</div>
@@ -78,14 +86,15 @@ import {
TabButtons,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { enablePlyr } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
const props = defineProps<{
lessonName: string
lessonTitle: string
lessonName?: string
lessonTitle?: string
}>()
const statistics = createListResource({
@@ -118,6 +127,12 @@ watch(
}
)
watch(show, () => {
if (show.value) {
enablePlyr()
}
})
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
@@ -143,6 +158,28 @@ const currentTabData = computed(() => {
return statisticsData.value[currentTab.value] || []
})
const isPlyrSource = computed(() => {
return (
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
)
})
const provider = computed(() => {
if (currentTab.value.includes('youtube')) {
return 'youtube'
} else if (currentTab.value.includes('vimeo')) {
return 'vimeo'
}
return ''
})
const embedURL = computed(() => {
if (isPlyrSource.value) {
return currentTab.value.replace('watch?v=', 'embed/')
}
return ''
})
const tabs = computed(() => {
return Object.keys(statisticsData.value).map((source, index) => ({
label: __(`Video ${index + 1}`),
@@ -150,3 +187,30 @@ const tabs = computed(() => {
}))
})
</script>
<style>
.plyr__volume input[type='range'] {
display: none;
}
.plyr__control--overlaid {
background: radial-gradient(
circle,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.5) 50%
);
}
.plyr__control:hover {
background: none;
}
.plyr--video {
border: 1px solid theme('colors.gray.200');
border-radius: 8px;
}
:root {
--plyr-range-fill-background: white;
--plyr-video-control-background-hover: transparent;
}
</style>

View File

@@ -312,6 +312,7 @@ const discussionsContainer = ref(null)
const timer = ref(0)
const { brand } = sessionStore()
const sidebarStore = useSidebar()
const plyrSources = ref([])
let timerInterval
const props = defineProps({
@@ -470,14 +471,15 @@ const switchLesson = (direction) => {
watch(
[() => route.params.chapterNumber, () => route.params.lessonNumber],
(
async (
[newChapterNumber, newLessonNumber],
[oldChapterNumber, oldLessonNumber]
) => {
if (newChapterNumber || newLessonNumber) {
plyrSources.value = []
await nextTick()
resetLessonState(newChapterNumber, newLessonNumber)
startTimer()
enablePlyr()
}
}
)
@@ -495,52 +497,104 @@ const resetLessonState = (newChapterNumber, newLessonNumber) => {
}
const trackVideoWatchDuration = () => {
const videoDetails = []
if (!lesson.data.membership) return
let videoDetails = getVideoDetails()
videoDetails = videoDetails.concat(getPlyrSourceDetails())
call('lms.lms.api.track_video_watch_duration', {
lesson: lesson.data.name,
videos: videoDetails,
})
}
const getVideoDetails = () => {
let details = []
const videos = document.querySelectorAll('video')
if (videos.length > 0 && lesson.data.membership) {
if (videos.length > 0) {
videos.forEach((video) => {
videoDetails.push({
details.push({
source: video.src,
watch_time: video.currentTime,
})
})
call('lms.lms.api.track_video_watch_duration', {
lesson: lesson.data.name,
videos: videoDetails,
})
}
return details
}
const getPlyrSourceDetails = () => {
let details = []
plyrSources.value.forEach((source) => {
let src = cleanYouTubeUrl(source.source)
details.push({
source: src,
watch_time: source.currentTime,
})
})
return details
}
const cleanYouTubeUrl = (url) => {
if (!url) return url
const urlObj = new URL(url)
urlObj.searchParams.delete('t')
return urlObj.toString()
}
watch(
() => lesson.data,
(data) => {
async (data) => {
setupLesson(data)
enablePlyr()
updateVideoWatchDuration()
getPlyrSource()
}
)
const getPlyrSource = async () => {
await nextTick()
if (plyrSources.value.length == 0) {
plyrSources.value = await 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
})
}
}
if (lesson.data.videos && lesson.data.videos.length > 0) {
lesson.data.videos.forEach((video) => {
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
updatePlyrVideoTime(video)
} else {
updateVideoTime(video)
}
})
}
}
const updatePlyrVideoTime = (video) => {
plyrSources.value.forEach((plyrSource) => {
plyrSource.on('ready', () => {
if (plyrSource.source === video.source) {
plyrSource.embed.seekTo(video.watch_time, true)
plyrSource.play()
plyrSource.pause()
}
})
})
}
const updateVideoTime = (video) => {
const videos = document.querySelectorAll('video')
if (videos.length > 0) {
videos.forEach((vid) => {
if (vid.src === video.source) {
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
if (vid.readyState >= 1) {
vid.currentTime = watch_time
} else {
vid.addEventListener('loadedmetadata', () => {
vid.currentTime = watch_time
})
}
})
}
}, 10)
}
})
}
}
const startTimer = () => {

View File

@@ -531,33 +531,58 @@ export const canCreateCourse = () => {
)
}
export const enablePlyr = () => {
setTimeout(() => {
const videoElement = document.getElementsByClassName('video-player')
if (videoElement.length === 0) return
export const enablePlyr = async () => {
await wait(500)
Array.from(videoElement).forEach((video) => {
const src = video.getAttribute('src')
if (src) {
let videoID = src.split('/').pop()
video.setAttribute('data-plyr-embed-id', videoID)
}
new Plyr(video, {
youtube: {
noCookie: true,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
}, 500)
const players = []
const videoElements = document.getElementsByClassName('video-player')
if (videoElements.length === 0) return players
Array.from(videoElements).forEach((video) => {
setupPlyrForVideo(video, players)
})
return players
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const setupPlyrForVideo = (video, players) => {
const src = video.getAttribute('src') || video.getAttribute('data-src')
if (src) {
const videoID = extractYouTubeId(src)
video.setAttribute('data-plyr-provider', 'youtube')
video.setAttribute('data-plyr-embed-id', videoID)
}
const player = new Plyr(video, {
youtube: { noCookie: true },
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
})
players.push(player)
}
const extractYouTubeId = (url) => {
try {
const parsedUrl = new URL(url)
return (
parsedUrl.searchParams.get('v') ||
parsedUrl.pathname.split('/').pop()
)
} catch {
return url.split('/').pop()
}
}
export const openSettings = (category, close = null) => {
@@ -567,7 +592,6 @@ export const openSettings = (category, close = null) => {
}
settingsStore.activeTab = category
settingsStore.isSettingsOpen = true
console.log(settingsStore.activeTab, settingsStore.isSettingsOpen)
}
export const cleanError = (message) => {

View File

@@ -1575,15 +1575,17 @@ def track_video_watch_duration(lesson, videos):
"source": video.get("source"),
"member": frappe.session.user,
}
if frappe.db.exists("LMS Video Watch Duration", filters):
existing_record = frappe.db.get_value(
"LMS Video Watch Duration", filters, ["name", "watch_time"], as_dict=True
)
if existing_record and existing_record.watch_time < video.get("watch_time"):
frappe.db.set_value(
"LMS Video Watch Duration",
filters,
"watch_time",
video.get("watch_time"),
)
else:
elif not existing_record:
track_new_watch_time(lesson, video)