| 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 |
|