PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.9.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.9.0
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
fluent-community / Modules / Course / js / lesson-video-gate.js

lesson-video-gate.js in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.9.0, at Modules/Course/js/lesson-video-gate.js

444 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * FluentCommunity — video-gated lesson completion (portal tracker).
3 *
4 * Listens for the FComMediaReady event the portal dispatches when a lesson
5 * with FluentPlayer media mounts, tracks genuinely watched segments of the
6 * lesson's feature video (seek-jumps don't count), and persists the
7 * "watched" state to the server once the lesson's threshold is crossed so
8 * the completion gate lets "Complete Lesson" succeed.
9 */
10 (function () {
11 'use strict';
12
13 var VARS = window.fcomLessonVideoGate || {};
14 var current = null; // active tracker for the mounted lesson
15
16 function getRest() {
17 // Prefer the SPA's live nonce — it gets refreshed via the
18 // fcom_renew_rest_nonce flow while the localized snapshot goes stale
19 var admin = window.fluentComAdmin;
20 var live = (admin && admin.rest) || {};
21 return {
22 url: VARS.rest_url || live.url || '',
23 nonce: live.nonce || VARS.nonce || ''
24 };
25 }
26
27 function markWatched(courseId, lessonId, percent) {
28 var rest = getRest();
29 if (!rest.url || !rest.nonce) {
30 return Promise.reject(new Error('Missing REST config'));
31 }
32 var url = rest.url.replace(/\/$/, '') + '/courses/' + courseId + '/lessons/' + lessonId + '/video-watched';
33 return fetch(url, {
34 method: 'POST',
35 credentials: 'same-origin',
36 headers: {
37 'Content-Type': 'application/json;charset=UTF-8',
38 'X-WP-Nonce': rest.nonce
39 },
40 body: JSON.stringify({watched_percent: Math.round(percent)})
41 }).then(function (res) {
42 if (!res.ok) {
43 return res.json().catch(function () {
44 return {};
45 }).then(function (body) {
46 var error = new Error('Request failed: ' + res.status);
47 error.status = res.status;
48 error.code = body && body.code;
49 throw error;
50 });
51 }
52 return res.json();
53 });
54 }
55
56 function sprintfD(template, num) {
57 return (template || '').replace(/%(?:\d+\$)?d/, String(num)).replace(/%%/g, '%');
58 }
59
60 function createNotice(playerEl, threshold) {
61 var notice = document.createElement('div');
62 notice.className = 'fcom_video_gate_notice';
63 notice.setAttribute('role', 'status');
64 notice.style.cssText = 'margin:10px 0;padding:10px 14px;border-radius:6px;font-size:14px;' +
65 'background:rgba(230,162,60,.12);color:inherit;border:1px solid rgba(230,162,60,.4);';
66 var i18n = VARS.i18n || {};
67 notice.textContent = threshold >= 100
68 ? (i18n.watch_to_end || 'Watch this video to the end to be able to complete this lesson.')
69 : sprintfD(i18n.watch_notice || 'Watch at least %d%% of this video to be able to complete this lesson.', threshold);
70 if (playerEl.parentNode) {
71 playerEl.parentNode.insertBefore(notice, playerEl.nextSibling);
72 }
73 return notice;
74 }
75
76 function markNoticeDone(notice) {
77 if (!notice) {
78 return;
79 }
80 var i18n = VARS.i18n || {};
81 notice.textContent = i18n.watched_done || 'Video watched — you can now complete this lesson.';
82 notice.style.background = 'rgba(0,138,46,.12)';
83 notice.style.borderColor = 'rgba(0,138,46,.4)';
84 }
85
86 function markNoticeFailed(notice) {
87 if (!notice) {
88 return;
89 }
90 var i18n = VARS.i18n || {};
91 notice.textContent = i18n.watch_failed || 'Could not record your watch progress. Please reload the page and try again.';
92 notice.style.background = 'rgba(245,108,108,.12)';
93 notice.style.borderColor = 'rgba(245,108,108,.4)';
94 }
95
96 /**
97 * Segment-based watch tracker on the <media-player> element.
98 * Only continuous playback accumulates; seeking past content does not.
99 */
100 function createTracker(playerEl, opts) {
101 var segments = [];
102 var segStart = null;
103 var lastTime = null;
104 var seeking = false;
105 var completed = false;
106 var posting = false;
107 var failed = false;
108 var attempts = 0;
109 var nextRetryAt = 0;
110 var renewalRequested = false;
111 var lastEvalSecond = -1;
112 var fullyDispatched = false;
113 var pendingAutoComplete = false;
114 var listeners = [];
115
116 function now() {
117 return playerEl.currentTime || 0;
118 }
119
120 function duration() {
121 return playerEl.duration || 0;
122 }
123
124 function closeSegment() {
125 if (segStart === null || lastTime === null) {
126 return;
127 }
128 if (lastTime - segStart > 0.1) {
129 segments.push({start: segStart, end: lastTime});
130 mergeSegments();
131 }
132 segStart = null;
133 }
134
135 function mergeSegments() {
136 segments.sort(function (a, b) {
137 return a.start - b.start;
138 });
139 var merged = [];
140 segments.forEach(function (seg) {
141 var last = merged[merged.length - 1];
142 if (last && seg.start <= last.end + 0.5) {
143 last.end = Math.max(last.end, seg.end);
144 } else {
145 merged.push({start: seg.start, end: seg.end});
146 }
147 });
148 segments = merged;
149 }
150
151 function watchedPercent() {
152 var total = duration();
153 if (!total) {
154 return 0;
155 }
156 // Include the still-running segment so the threshold is detected
157 // as soon as it is crossed, not only after the segment is banked
158 var all = segments.slice();
159 if (segStart !== null && lastTime !== null && lastTime - segStart > 0.1) {
160 all.push({start: segStart, end: lastTime});
161 }
162 all.sort(function (a, b) {
163 return a.start - b.start;
164 });
165 var watched = 0;
166 var cursor = -1;
167 all.forEach(function (seg) {
168 var start = Math.max(seg.start, cursor);
169 if (seg.end > start) {
170 watched += seg.end - start;
171 cursor = seg.end;
172 } else {
173 cursor = Math.max(cursor, seg.end);
174 }
175 });
176 return Math.min(100, (watched / total) * 100);
177 }
178
179 function complete(percent) {
180 if (completed || posting || failed) {
181 return;
182 }
183 if (Date.now() < nextRetryAt) {
184 return;
185 }
186 posting = true;
187 markWatched(opts.courseId, opts.lessonId, percent)
188 .then(function () {
189 completed = true;
190 markNoticeDone(opts.notice);
191 document.dispatchEvent(new CustomEvent('FComLessonVideoWatched', {
192 detail: {lessonId: opts.lessonId, courseId: opts.courseId}
193 }));
194 if (pendingAutoComplete) {
195 maybeAutoComplete();
196 }
197 })
198 .catch(function (error) {
199 var status = (error && error.status) || 0;
200
201 // Stale REST nonce: ask the SPA to renew it once, then retry
202 if (status === 403 && error.code === 'rest_cookie_invalid_nonce' && !renewalRequested) {
203 renewalRequested = true;
204 document.dispatchEvent(new CustomEvent('fcom_renew_rest_nonce'));
205 nextRetryAt = Date.now() + 3000;
206 return;
207 }
208
209 // Other 4xx (not enrolled, gate off, auth) will not heal — stop
210 if (status >= 400 && status < 500) {
211 failed = true;
212 markNoticeFailed(opts.notice);
213 return;
214 }
215
216 // Network/5xx: capped exponential backoff, then give up
217 attempts++;
218 if (attempts >= 5) {
219 failed = true;
220 markNoticeFailed(opts.notice);
221 return;
222 }
223 nextRetryAt = Date.now() + Math.min(60000, 2000 * Math.pow(2, attempts));
224 })
225 .finally(function () {
226 posting = false;
227 });
228 }
229
230 function evaluate(isEnded) {
231 if (!opts.gated || completed) {
232 return;
233 }
234 if (isEnded) {
235 complete(100);
236 return;
237 }
238 var percent = watchedPercent();
239 if (percent >= opts.threshold) {
240 complete(percent);
241 }
242 }
243
244 function maybeAutoComplete() {
245 if (!opts.autoComplete || fullyDispatched) {
246 return;
247 }
248 // "100%" means genuinely watched to the end — seeking to the end
249 // fires ended with low coverage and must not auto-complete
250 if (watchedPercent() < 99.5) {
251 return;
252 }
253 // When gated, the watched record must land before the completion
254 // request, or the gate would reject it
255 if (opts.gated && !completed) {
256 if (!failed) {
257 pendingAutoComplete = true;
258 }
259 return;
260 }
261 fullyDispatched = true;
262 document.dispatchEvent(new CustomEvent('FComLessonVideoFullyWatched', {
263 detail: {lessonId: opts.lessonId, courseId: opts.courseId}
264 }));
265 }
266
267 function on(event, handler) {
268 playerEl.addEventListener(event, handler);
269 listeners.push([event, handler]);
270 }
271
272 on('play', function () {
273 segStart = now();
274 lastTime = segStart;
275 });
276
277 on('seeking', function () {
278 closeSegment();
279 seeking = true;
280 });
281
282 on('seeked', function () {
283 seeking = false;
284 segStart = now();
285 lastTime = segStart;
286 });
287
288 on('pause', function () {
289 closeSegment();
290 evaluate(false);
291 });
292
293 on('time-update', function () {
294 if (seeking) {
295 return;
296 }
297 var t = now();
298 if (segStart === null) {
299 segStart = t;
300 lastTime = t;
301 return;
302 }
303 // A jump the player made without firing seeking (or a missed
304 // event) must not count as watched time
305 if (lastTime !== null && Math.abs(t - lastTime) > 2) {
306 closeSegment();
307 segStart = t;
308 }
309 lastTime = t;
310 // Periodically bank the running segment so progress survives
311 // without waiting for a pause
312 if (t - segStart > 5) {
313 closeSegment();
314 segStart = t;
315 }
316 // time-update fires per animation frame — evaluating once per
317 // playback second is plenty for threshold detection
318 var second = Math.floor(t);
319 if (second !== lastEvalSecond) {
320 lastEvalSecond = second;
321 evaluate(false);
322 }
323 });
324
325 on('ended', function () {
326 lastTime = now();
327 closeSegment();
328 evaluate(true);
329 maybeAutoComplete();
330 });
331
332 return {
333 teardown: function () {
334 listeners.forEach(function (pair) {
335 playerEl.removeEventListener(pair[0], pair[1]);
336 });
337 listeners = [];
338 if (opts.notice && opts.notice.parentNode) {
339 opts.notice.parentNode.removeChild(opts.notice);
340 }
341 }
342 };
343 }
344
345 function findPlayer(cb) {
346 var cancelled = false;
347 var attempt = 0;
348 var timer = null;
349
350 function tick() {
351 if (cancelled) {
352 return;
353 }
354 var el = document.querySelector('.fcom_lesson_content media-player');
355 if (el) {
356 cb(el);
357 return;
358 }
359 if (++attempt >= 40) {
360 return; // ~10s, give up quietly
361 }
362 timer = setTimeout(tick, 250);
363 }
364
365 tick();
366
367 return {
368 cancel: function () {
369 cancelled = true;
370 clearTimeout(timer);
371 }
372 };
373 }
374
375 var lastDetail = null;
376
377 function startGateSession(detail, ignoreWatched) {
378 var lesson = detail.feed || {};
379 var meta = lesson.meta || {};
380
381 if (current) {
382 current.teardown();
383 current = null;
384 }
385
386 // Server-computed flags — single source of truth
387 var autoComplete = !!meta.video_auto_complete;
388 // The gate session is pointless once the watch is recorded, but a
389 // fully-watched replay must still be able to auto-complete
390 var gated = !!meta.is_video_gated && (!meta.video_watched || ignoreWatched);
391
392 if ((!gated && !autoComplete) || !lesson.id || !lesson.course_id) {
393 return;
394 }
395
396 var threshold = Math.min(100, Math.max(1, parseInt(meta.video_completion_threshold) || parseInt(VARS.default_threshold) || 80));
397
398 var session = {
399 forLesson: lesson.id,
400 tracker: null,
401 finder: null,
402 teardown: function () {
403 if (this.finder) {
404 this.finder.cancel();
405 }
406 if (this.tracker) {
407 this.tracker.teardown();
408 }
409 }
410 };
411 current = session;
412
413 session.finder = findPlayer(function (playerEl) {
414 if (current !== session) {
415 return; // another lesson mounted while waiting
416 }
417 var notice = gated ? createNotice(playerEl, threshold) : null;
418 session.tracker = createTracker(playerEl, {
419 courseId: lesson.course_id,
420 lessonId: lesson.id,
421 threshold: threshold,
422 gated: gated,
423 autoComplete: autoComplete,
424 notice: notice
425 });
426 });
427 }
428
429 document.addEventListener('FComMediaReady', function (event) {
430 lastDetail = event.detail || {};
431 startGateSession(lastDetail, false);
432 });
433
434 // The SPA dispatches this when a gated lesson is un-completed — the
435 // watched record is wiped server side, so the video must be re-watched
436 document.addEventListener('FComLessonVideoUnwatched', function (event) {
437 var lessonId = event.detail && event.detail.lessonId;
438 if (!lastDetail || !lastDetail.feed || lastDetail.feed.id != lessonId) {
439 return;
440 }
441 startGateSession(lastDetail, true);
442 });
443 })();
444