From 5eaae06ceb5954e8d87c791efeca9413cde0ff7a Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Mon, 30 Jun 2025 19:56:07 +0530 Subject: [PATCH 1/6] feat: video watch time tracking --- frontend/components.d.ts | 1 + .../src/components/Modals/VideoStatistics.vue | 143 +++++++++++++++ frontend/src/components/VideoBlock.vue | 9 +- frontend/src/pages/Lesson.vue | 167 ++++++++++++------ lms/lms/api.py | 35 ++++ .../lms_video_watch_duration/__init__.py | 0 .../lms_video_watch_duration.js | 8 + .../lms_video_watch_duration.json | 160 +++++++++++++++++ .../lms_video_watch_duration.py | 9 + .../test_lms_video_watch_duration.py | 21 +++ lms/lms/utils.py | 9 + 11 files changed, 507 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/Modals/VideoStatistics.vue create mode 100644 lms/lms/doctype/lms_video_watch_duration/__init__.py create mode 100644 lms/lms/doctype/lms_video_watch_duration/lms_video_watch_duration.js create mode 100644 lms/lms/doctype/lms_video_watch_duration/lms_video_watch_duration.json create mode 100644 lms/lms/doctype/lms_video_watch_duration/lms_video_watch_duration.py create mode 100644 lms/lms/doctype/lms_video_watch_duration/test_lms_video_watch_duration.py diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 98fda927..907b9153 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -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'] } diff --git a/frontend/src/components/Modals/VideoStatistics.vue b/frontend/src/components/Modals/VideoStatistics.vue new file mode 100644 index 00000000..12c58625 --- /dev/null +++ b/frontend/src/components/Modals/VideoStatistics.vue @@ -0,0 +1,143 @@ + + diff --git a/frontend/src/components/VideoBlock.vue b/frontend/src/components/VideoBlock.vue index 3f5963b6..42a22cd1 100644 --- a/frontend/src/components/VideoBlock.vue +++ b/frontend/src/components/VideoBlock.vue @@ -27,9 +27,9 @@ oncontextmenu="return false" class="rounded-md border border-gray-100 cursor-pointer" ref="videoRef" - > - - + :src="fileURL" + :type="type" + >
{}, }, }) diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index 430c1b28..daa48934 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -12,6 +12,12 @@ +
@@ -100,26 +106,15 @@ - - - + + - - + + + + diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index daa48934..830cc712 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -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 = () => { diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index e429a59f..a9ce4fe2 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -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) => { diff --git a/lms/lms/api.py b/lms/lms/api.py index fca1f1c9..cbd9585d 100644 --- a/lms/lms/api.py +++ b/lms/lms/api.py @@ -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) From 94cbbf169a730e17391d4a1eb9aa0414330c779d Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 1 Jul 2025 17:27:43 +0530 Subject: [PATCH 4/6] feat: prevent skipping videos --- frontend/src/components/Settings/Settings.vue | 31 ++++++++++++------- frontend/src/components/VideoBlock.vue | 3 ++ frontend/src/pages/Lesson.vue | 1 + frontend/src/stores/settings.js | 14 +++++++-- frontend/src/utils/index.js | 25 +++++++++------ lms/lms/api.py | 9 ++---- .../doctype/lms_settings/lms_settings.json | 15 +++++++-- 7 files changed, 65 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/Settings/Settings.vue b/frontend/src/components/Settings/Settings.vue index 26fc3e8f..a4e219a3 100644 --- a/frontend/src/components/Settings/Settings.vue +++ b/frontend/src/components/Settings/Settings.vue @@ -130,6 +130,13 @@ const tabsStructure = computed(() => { label: 'General', icon: 'Wrench', fields: [ + { + label: 'Allow Guest Access', + name: 'allow_guest_access', + description: + 'If enabled, users can access the course and batch lists without logging in.', + type: 'checkbox', + }, { label: 'Enable Learning Paths', name: 'enable_learning_paths', @@ -138,11 +145,11 @@ const tabsStructure = computed(() => { type: 'checkbox', }, { - label: 'Allow Guest Access', - name: 'allow_guest_access', - description: - 'If enabled, users can access the course and batch lists without logging in.', + label: 'Prevent Skipping Videos', + name: 'prevent_skipping_videos', type: 'checkbox', + description: + 'If enabled, students cannot skip videos in a lesson.', }, { label: 'Send calendar invite for evaluations', @@ -154,6 +161,14 @@ const tabsStructure = computed(() => { { type: 'Column Break', }, + { + label: 'Livecode URL', + name: 'livecode_url', + doctype: 'Livecode URL', + type: 'text', + description: + 'https://docs.frappe.io/learning/falcon-self-hosting-guide', + }, { label: 'Batch Confirmation Email Template', name: 'batch_confirmation_template', @@ -166,14 +181,6 @@ const tabsStructure = computed(() => { doctype: 'Email Template', type: 'Link', }, - { - label: 'Livecode URL', - name: 'livecode_url', - doctype: 'Livecode URL', - type: 'text', - description: - 'https://docs.frappe.io/learning/falcon-self-hosting-guide', - }, { label: 'Unsplash Access Key', name: 'unsplash_access_key', diff --git a/frontend/src/components/VideoBlock.vue b/frontend/src/components/VideoBlock.vue index 42a22cd1..86ddfa87 100644 --- a/frontend/src/components/VideoBlock.vue +++ b/frontend/src/components/VideoBlock.vue @@ -74,6 +74,7 @@ v-model="currentTime" @input="changeCurrentTime" class="duration-slider h-1" + :disabled="preventSkippingVideos.data" />
@@ -155,6 +156,7 @@ import { ref, onMounted, computed, watch } from 'vue' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Button, Dialog } from 'frappe-ui' import { formatSeconds, formatTimestamp } from '@/utils' +import { useSettings } from '@/stores/settings' import Play from '@/components/Icons/Play.vue' import QuizInVideo from '@/components/Modals/QuizInVideo.vue' @@ -170,6 +172,7 @@ const showQuizLoader = ref(false) const quizLoadTimer = ref(0) const currentQuiz = ref(null) const nextQuiz = ref({}) +const { preventSkippingVideos } = useSettings() const props = defineProps({ file: { diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index 830cc712..afb36b4e 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -551,6 +551,7 @@ const getPlyrSource = async () => { await nextTick() if (plyrSources.value.length == 0) { plyrSources.value = await enablePlyr() + console.log(plyrSources.value) } updateVideoWatchDuration() } diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 85ad5156..205354ba 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -9,21 +9,31 @@ export const useSettings = defineStore('settings', () => { const activeTab = ref(null) const learningPaths = createResource({ - url: 'lms.lms.api.is_learning_path_enabled', + url: 'lms.lms.api.get_lms_setting', + params: { field: 'enable_learning_paths' }, auto: true, cache: ['learningPath'], }) const allowGuestAccess = createResource({ - url: 'lms.lms.api.is_guest_allowed', + url: 'lms.lms.api.get_lms_setting', + params: { field: 'allow_guest_access' }, auto: true, cache: ['allowGuestAccess'], }) + const preventSkippingVideos = createResource({ + url: 'lms.lms.api.get_lms_setting', + params: { field: 'prevent_skipping_videos' }, + auto: true, + cache: ['preventSkippingVideos'], + }) + return { isSettingsOpen, activeTab, learningPaths, allowGuestAccess, + preventSkippingVideos, } }) diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index a9ce4fe2..79ec05ee 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -1,4 +1,3 @@ -import { watch } from 'vue' import { call, toast } from 'frappe-ui' import { useTimeAgo } from '@vueuse/core' import { Quiz } from '@/utils/quiz' @@ -557,17 +556,23 @@ const setupPlyrForVideo = (video, players) => { video.setAttribute('data-plyr-embed-id', videoID) } + let controls = [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'mute', + 'volume', + 'fullscreen', + ] + + if (useSettings().preventSkippingVideos.data) { + controls.splice(controls.indexOf('progress'), 1) + } + const player = new Plyr(video, { youtube: { noCookie: true }, - controls: [ - 'play-large', - 'play', - 'progress', - 'current-time', - 'mute', - 'volume', - 'fullscreen', - ], + controls: controls, }) players.push(player) diff --git a/lms/lms/api.py b/lms/lms/api.py index cbd9585d..d9d9de51 100644 --- a/lms/lms/api.py +++ b/lms/lms/api.py @@ -1304,13 +1304,8 @@ def get_notifications(filters): @frappe.whitelist(allow_guest=True) -def is_guest_allowed(): - return frappe.get_cached_value("LMS Settings", None, "allow_guest_access") - - -@frappe.whitelist(allow_guest=True) -def is_learning_path_enabled(): - return frappe.get_cached_value("LMS Settings", None, "enable_learning_paths") +def get_lms_setting(field): + return frappe.get_cached_value("LMS Settings", None, field) @frappe.whitelist() diff --git a/lms/lms/doctype/lms_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index d1984e6e..3f3f3b4b 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -12,6 +12,8 @@ "column_break_zdel", "allow_guest_access", "enable_learning_paths", + "prevent_skipping_videos", + "column_break_bjis", "unsplash_access_key", "livecode_url", "section_break_szgq", @@ -72,7 +74,6 @@ "default": "https://livecode.dev.fossunited.org", "fieldname": "livecode_url", "fieldtype": "Data", - "hidden": 1, "label": "LiveCode URL" }, { @@ -405,13 +406,23 @@ "fieldname": "certified_members", "fieldtype": "Check", "label": "Certified Members" + }, + { + "default": "0", + "fieldname": "prevent_skipping_videos", + "fieldtype": "Check", + "label": "Prevent Skipping Videos" + }, + { + "fieldname": "column_break_bjis", + "fieldtype": "Column Break" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-05-30 19:02:51.381668", + "modified": "2025-07-01 17:01:58.466697", "modified_by": "sayali@frappe.io", "module": "LMS", "name": "LMS Settings", From e526a6fd64391d28f4c25427e05c70fb83c538de Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 1 Jul 2025 17:38:15 +0530 Subject: [PATCH 5/6] fix: moved sirebar settings to settings store --- frontend/src/components/AppSidebar.vue | 3 ++- frontend/src/components/MobileLayout.vue | 4 +++- frontend/src/stores/session.js | 7 ------- frontend/src/stores/settings.js | 7 +++++++ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 9a96b146..3d7ce6f2 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -225,7 +225,7 @@ import { IntermediateStepModal, } from 'frappe-ui/frappe' -const { user, sidebarSettings } = sessionStore() +const { user } = sessionStore() const { userResource } = usersStore() let sidebarStore = useSidebar() const socket = inject('$socket') @@ -236,6 +236,7 @@ const isModerator = ref(false) const isInstructor = ref(false) const pageToEdit = ref(null) const settingsStore = useSettings() +const { sidebarSettings } = settingsStore const showOnboarding = ref(false) const showIntermediateModal = ref(false) const currentStep = ref({}) diff --git a/frontend/src/components/MobileLayout.vue b/frontend/src/components/MobileLayout.vue index 1288c110..40cb3855 100644 --- a/frontend/src/components/MobileLayout.vue +++ b/frontend/src/components/MobileLayout.vue @@ -58,11 +58,13 @@ import { getSidebarLinks } from '@/utils' import { useRouter } from 'vue-router' import { watch, ref, onMounted } from 'vue' import { sessionStore } from '@/stores/session' +import { useSettings } from '@/stores/settings' import { usersStore } from '@/stores/user' import * as icons from 'lucide-vue-next' -const { logout, user, sidebarSettings } = sessionStore() +const { logout, user } = sessionStore() let { isLoggedIn } = sessionStore() +const { sidebarSettings } = useSettings() const router = useRouter() let { userResource } = usersStore() const sidebarLinks = ref(getSidebarLinks()) diff --git a/frontend/src/stores/session.js b/frontend/src/stores/session.js index c78ae9af..17697079 100644 --- a/frontend/src/stores/session.js +++ b/frontend/src/stores/session.js @@ -54,12 +54,6 @@ export const sessionStore = defineStore('lms-session', () => { }, }) - const sidebarSettings = createResource({ - url: 'lms.lms.api.get_sidebar_settings', - cache: 'Sidebar Settings', - auto: false, - }) - const livecodeURL = createResource({ url: 'frappe.client.get_single_value', params: { @@ -77,7 +71,6 @@ export const sessionStore = defineStore('lms-session', () => { logout, brand, branding, - sidebarSettings, livecodeURL, } }) diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 205354ba..b323ef0f 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -29,11 +29,18 @@ export const useSettings = defineStore('settings', () => { cache: ['preventSkippingVideos'], }) + const sidebarSettings = createResource({ + url: 'lms.lms.api.get_sidebar_settings', + cache: 'Sidebar Settings', + auto: false, + }) + return { isSettingsOpen, activeTab, learningPaths, allowGuestAccess, preventSkippingVideos, + sidebarSettings, } }) From b4cf290f4da2e7336312a55e0f576f620c82f0f8 Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Tue, 1 Jul 2025 19:16:13 +0530 Subject: [PATCH 6/6] fix: allow backward seek but prevent forward seek --- frontend/src/components/VideoBlock.vue | 6 +++++- frontend/src/pages/Lesson.vue | 7 ++++++- frontend/src/utils/index.js | 29 ++++++++++++++++++++++---- lms/lms/utils.py | 1 + 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/VideoBlock.vue b/frontend/src/components/VideoBlock.vue index 86ddfa87..693d6d26 100644 --- a/frontend/src/components/VideoBlock.vue +++ b/frontend/src/components/VideoBlock.vue @@ -74,7 +74,6 @@ v-model="currentTime" @input="changeCurrentTime" class="duration-slider h-1" - :disabled="preventSkippingVideos.data" />
@@ -299,6 +298,11 @@ const toggleMute = () => { } const changeCurrentTime = () => { + if ( + preventSkippingVideos.data && + currentTime.value > videoRef.value.currentTime + ) + return videoRef.value.currentTime = currentTime.value updateNextQuiz() } diff --git a/frontend/src/pages/Lesson.vue b/frontend/src/pages/Lesson.vue index afb36b4e..48dbb6e4 100644 --- a/frontend/src/pages/Lesson.vue +++ b/frontend/src/pages/Lesson.vue @@ -511,6 +511,7 @@ const getVideoDetails = () => { const videos = document.querySelectorAll('video') if (videos.length > 0) { videos.forEach((video) => { + if (video.currentTime == video.duration) markProgress() details.push({ source: video.src, watch_time: video.currentTime, @@ -523,6 +524,7 @@ const getVideoDetails = () => { const getPlyrSourceDetails = () => { let details = [] plyrSources.value.forEach((source) => { + if (source.currentTime == source.duration) markProgress() let src = cleanYouTubeUrl(source.source) details.push({ source: src, @@ -544,6 +546,7 @@ watch( async (data) => { setupLesson(data) getPlyrSource() + if (data.icon == 'icon-youtube') clearInterval(timerInterval) } ) @@ -551,7 +554,6 @@ const getPlyrSource = async () => { await nextTick() if (plyrSources.value.length == 0) { plyrSources.value = await enablePlyr() - console.log(plyrSources.value) } updateVideoWatchDuration() } @@ -570,6 +572,9 @@ const updateVideoWatchDuration = () => { const updatePlyrVideoTime = (video) => { plyrSources.value.forEach((plyrSource) => { + let lastWatchedTime = 0 + let isSeeking = false + plyrSource.on('ready', () => { if (plyrSource.source === video.source) { plyrSource.embed.seekTo(video.watch_time, true) diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 79ec05ee..836912b9 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -566,18 +566,39 @@ const setupPlyrForVideo = (video, players) => { 'fullscreen', ] - if (useSettings().preventSkippingVideos.data) { - controls.splice(controls.indexOf('progress'), 1) - } - const player = new Plyr(video, { youtube: { noCookie: true }, controls: controls, + listeners: { + seek: function customSeekBehavior(e) { + const current_time = player.currentTime + const newTime = getTargetTime(player, e) + if ( + useSettings().preventSkippingVideos.data && + parseFloat(newTime) > current_time + ) { + e.preventDefault() + player.currentTime = current_time + return false + } + }, + }, }) players.push(player) } +const getTargetTime = (plyr, input) => { + if ( + typeof input === 'object' && + (input.type === 'input' || input.type === 'change') + ) { + return (input.target.value / input.target.max) * plyr.duration + } else { + return Number(input) + } +} + const extractYouTubeId = (url) => { try { const parsedUrl = new URL(url) diff --git a/lms/lms/utils.py b/lms/lms/utils.py index 131fff51..4ec61ba1 100644 --- a/lms/lms/utils.py +++ b/lms/lms/utils.py @@ -1320,6 +1320,7 @@ def get_lesson(course, chapter, lesson): lesson_details.progress = progress lesson_details.prev = neighbours["prev"] lesson_details.membership = membership + lesson_details.icon = get_lesson_icon(lesson_details.body, lesson_details.content) lesson_details.instructors = get_instructors("LMS Course", course) lesson_details.course_title = course_info.title lesson_details.paid_certificate = course_info.paid_certificate