feat: track watch time for youtube and vimeo
This commit is contained in:
@@ -8,7 +8,12 @@
|
|||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="text-base">
|
<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 v-if="currentTab" class="mt-8">
|
||||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||||
<div class="space-y-5 border rounded-md p-2 pt-4">
|
<div class="space-y-5 border rounded-md p-2 pt-4">
|
||||||
@@ -47,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center text-sm">
|
<div class="text-center text-sm">
|
||||||
{{ row.watch_time }}
|
{{ parseFloat(row.watch_time).toFixed(2) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -61,7 +66,10 @@
|
|||||||
value: averageWatchTime,
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,14 +86,15 @@ import {
|
|||||||
TabButtons,
|
TabButtons,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { enablePlyr } from '@/utils'
|
||||||
import VideoBlock from '@/components/VideoBlock.vue'
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
|
||||||
const show = defineModel<boolean | undefined>()
|
const show = defineModel<boolean | undefined>()
|
||||||
const currentTab = ref<string>('')
|
const currentTab = ref<string>('')
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
lessonName: string
|
lessonName?: string
|
||||||
lessonTitle: string
|
lessonTitle?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statistics = createListResource({
|
const statistics = createListResource({
|
||||||
@@ -118,6 +127,12 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
enablePlyr()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const statisticsData = computed(() => {
|
const statisticsData = computed(() => {
|
||||||
const grouped = <Record<string, any[]>>{}
|
const grouped = <Record<string, any[]>>{}
|
||||||
statistics.data.forEach((item: { source: string }) => {
|
statistics.data.forEach((item: { source: string }) => {
|
||||||
@@ -143,6 +158,28 @@ const currentTabData = computed(() => {
|
|||||||
return statisticsData.value[currentTab.value] || []
|
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(() => {
|
const tabs = computed(() => {
|
||||||
return Object.keys(statisticsData.value).map((source, index) => ({
|
return Object.keys(statisticsData.value).map((source, index) => ({
|
||||||
label: __(`Video ${index + 1}`),
|
label: __(`Video ${index + 1}`),
|
||||||
@@ -150,3 +187,30 @@ const tabs = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
</script>
|
</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 timer = ref(0)
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const sidebarStore = useSidebar()
|
const sidebarStore = useSidebar()
|
||||||
|
const plyrSources = ref([])
|
||||||
let timerInterval
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -470,14 +471,15 @@ const switchLesson = (direction) => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||||
(
|
async (
|
||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber || newLessonNumber) {
|
||||||
|
plyrSources.value = []
|
||||||
|
await nextTick()
|
||||||
resetLessonState(newChapterNumber, newLessonNumber)
|
resetLessonState(newChapterNumber, newLessonNumber)
|
||||||
startTimer()
|
startTimer()
|
||||||
enablePlyr()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -495,52 +497,104 @@ const resetLessonState = (newChapterNumber, newLessonNumber) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trackVideoWatchDuration = () => {
|
const trackVideoWatchDuration = () => {
|
||||||
const videoDetails = []
|
if (!lesson.data.membership) return
|
||||||
const videos = document.querySelectorAll('video')
|
let videoDetails = getVideoDetails()
|
||||||
if (videos.length > 0 && lesson.data.membership) {
|
videoDetails = videoDetails.concat(getPlyrSourceDetails())
|
||||||
videos.forEach((video) => {
|
|
||||||
videoDetails.push({
|
|
||||||
source: video.src,
|
|
||||||
watch_time: video.currentTime,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
call('lms.lms.api.track_video_watch_duration', {
|
call('lms.lms.api.track_video_watch_duration', {
|
||||||
lesson: lesson.data.name,
|
lesson: lesson.data.name,
|
||||||
videos: videoDetails,
|
videos: videoDetails,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVideoDetails = () => {
|
||||||
|
let details = []
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((video) => {
|
||||||
|
details.push({
|
||||||
|
source: video.src,
|
||||||
|
watch_time: video.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
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(
|
watch(
|
||||||
() => lesson.data,
|
() => lesson.data,
|
||||||
(data) => {
|
async (data) => {
|
||||||
setupLesson(data)
|
setupLesson(data)
|
||||||
enablePlyr()
|
getPlyrSource()
|
||||||
updateVideoWatchDuration()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getPlyrSource = async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (plyrSources.value.length == 0) {
|
||||||
|
plyrSources.value = await enablePlyr()
|
||||||
|
}
|
||||||
|
updateVideoWatchDuration()
|
||||||
|
}
|
||||||
|
|
||||||
const updateVideoWatchDuration = () => {
|
const updateVideoWatchDuration = () => {
|
||||||
setTimeout(() => {
|
|
||||||
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
||||||
lesson.data.videos.forEach((video) => {
|
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')
|
const videos = document.querySelectorAll('video')
|
||||||
if (videos.length > 0) {
|
if (videos.length > 0) {
|
||||||
videos.forEach((vid) => {
|
videos.forEach((vid) => {
|
||||||
if (vid.src === video.source) {
|
if (vid.src === video.source) {
|
||||||
|
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
|
||||||
if (vid.readyState >= 1) {
|
if (vid.readyState >= 1) {
|
||||||
vid.currentTime = video.watch_time
|
vid.currentTime = watch_time
|
||||||
} else {
|
} else {
|
||||||
vid.addEventListener('loadedmetadata', () => {
|
vid.addEventListener('loadedmetadata', () => {
|
||||||
vid.currentTime = video.watch_time
|
vid.currentTime = watch_time
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
|
|||||||
@@ -531,21 +531,34 @@ export const canCreateCourse = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enablePlyr = () => {
|
export const enablePlyr = async () => {
|
||||||
setTimeout(() => {
|
await wait(500)
|
||||||
const videoElement = document.getElementsByClassName('video-player')
|
|
||||||
if (videoElement.length === 0) return
|
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')
|
||||||
|
|
||||||
Array.from(videoElement).forEach((video) => {
|
|
||||||
const src = video.getAttribute('src')
|
|
||||||
if (src) {
|
if (src) {
|
||||||
let videoID = src.split('/').pop()
|
const videoID = extractYouTubeId(src)
|
||||||
|
video.setAttribute('data-plyr-provider', 'youtube')
|
||||||
video.setAttribute('data-plyr-embed-id', videoID)
|
video.setAttribute('data-plyr-embed-id', videoID)
|
||||||
}
|
}
|
||||||
new Plyr(video, {
|
|
||||||
youtube: {
|
const player = new Plyr(video, {
|
||||||
noCookie: true,
|
youtube: { noCookie: true },
|
||||||
},
|
|
||||||
controls: [
|
controls: [
|
||||||
'play-large',
|
'play-large',
|
||||||
'play',
|
'play',
|
||||||
@@ -556,8 +569,20 @@ export const enablePlyr = () => {
|
|||||||
'fullscreen',
|
'fullscreen',
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}, 500)
|
|
||||||
})
|
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) => {
|
export const openSettings = (category, close = null) => {
|
||||||
@@ -567,7 +592,6 @@ export const openSettings = (category, close = null) => {
|
|||||||
}
|
}
|
||||||
settingsStore.activeTab = category
|
settingsStore.activeTab = category
|
||||||
settingsStore.isSettingsOpen = true
|
settingsStore.isSettingsOpen = true
|
||||||
console.log(settingsStore.activeTab, settingsStore.isSettingsOpen)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cleanError = (message) => {
|
export const cleanError = (message) => {
|
||||||
|
|||||||
@@ -1575,15 +1575,17 @@ def track_video_watch_duration(lesson, videos):
|
|||||||
"source": video.get("source"),
|
"source": video.get("source"),
|
||||||
"member": frappe.session.user,
|
"member": frappe.session.user,
|
||||||
}
|
}
|
||||||
|
existing_record = frappe.db.get_value(
|
||||||
if frappe.db.exists("LMS Video Watch Duration", filters):
|
"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(
|
frappe.db.set_value(
|
||||||
"LMS Video Watch Duration",
|
"LMS Video Watch Duration",
|
||||||
filters,
|
filters,
|
||||||
"watch_time",
|
"watch_time",
|
||||||
video.get("watch_time"),
|
video.get("watch_time"),
|
||||||
)
|
)
|
||||||
else:
|
elif not existing_record:
|
||||||
track_new_watch_time(lesson, video)
|
track_new_watch_time(lesson, video)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user