feat: track watch time for youtube and vimeo
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user