coursecode 0.1.57 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +47 -3
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +5 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +5 -1
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/vite.config.js +50 -22
@@ -90,11 +90,12 @@ export function init(container) {
90
90
  };
91
91
 
92
92
  // Handle transition end to set display: none for accessibility and performance
93
- panel.addEventListener('transitionend', () => {
93
+ const handleTransitionEnd = () => {
94
94
  if (trigger.getAttribute('aria-expanded') === 'false') {
95
95
  panel.style.display = 'none';
96
96
  }
97
- });
97
+ };
98
+ panel.addEventListener('transitionend', handleTransitionEnd);
98
99
 
99
100
  // Ensure panel is correctly displayed or hidden on init
100
101
  if (trigger.getAttribute('aria-expanded') === 'false') {
@@ -109,6 +110,7 @@ export function init(container) {
109
110
  return {
110
111
  destroy: () => {
111
112
  trigger.removeEventListener('click', toggle);
113
+ panel.removeEventListener('transitionend', handleTransitionEnd);
112
114
  }
113
115
  };
114
116
  }
@@ -127,7 +127,7 @@ export function init(root, options = {}) {
127
127
  });
128
128
 
129
129
  // Delegated event listener for the entire dropdown
130
- dropdown.addEventListener('click', (event) => {
130
+ const handleClick = (event) => {
131
131
  const actionTarget = event.target.closest('[data-action]');
132
132
  if (!actionTarget) return;
133
133
 
@@ -145,13 +145,17 @@ export function init(root, options = {}) {
145
145
  selectDropdownItem(dropdownId, itemIndex);
146
146
  }
147
147
  }
148
- });
148
+ };
149
149
 
150
150
  // Keyboard navigation for the menu
151
- menu.addEventListener('keydown', (event) => handleDropdownKeyboard(dropdownId, event));
151
+ const handleKeydown = (event) => handleDropdownKeyboard(dropdownId, event);
152
+ dropdown.addEventListener('click', handleClick);
153
+ menu.addEventListener('keydown', handleKeydown);
152
154
 
153
155
  return {
154
156
  destroy: () => {
157
+ dropdown.removeEventListener('click', handleClick);
158
+ menu.removeEventListener('keydown', handleKeydown);
155
159
  dropdowns.delete(dropdownId);
156
160
  }
157
161
  };
@@ -51,6 +51,7 @@ export const metadata = {
51
51
  import { logger } from '../../utilities/logger.js';
52
52
  import { eventBus } from '../../core/event-bus.js';
53
53
  import flagManager from '../../managers/flag-manager.js';
54
+ import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
54
55
 
55
56
  /**
56
57
  * Initializes an embed-frame component.
@@ -254,6 +255,9 @@ function handleReadyMessage(embedId) {
254
255
  * @returns {string} Resolved path
255
256
  */
256
257
  function _resolvePath(src) {
258
+ const portableSrc = resolvePortableAssetUrl(src);
259
+ if (portableSrc !== src) return portableSrc;
260
+
257
261
  // Already absolute URL or protocol-relative
258
262
  if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')) {
259
263
  return src;
@@ -270,5 +274,5 @@ function _resolvePath(src) {
270
274
  }
271
275
 
272
276
  // Otherwise, assume relative to course/assets/
273
- return `./course/${src}`;
277
+ return resolvePortableAssetUrl(`./course/${src}`);
274
278
  }
@@ -218,13 +218,19 @@ export function init(element) {
218
218
  announceToScreenReader(action);
219
219
  };
220
220
 
221
- // Click listener
222
- element.addEventListener('click', toggleFlip);
223
-
224
- // Keyboard listener (Enter or Space)
225
- element.addEventListener('keydown', (e) => {
221
+ const handleKeydown = (e) => {
226
222
  if (e.key === 'Enter' || e.key === ' ') {
227
223
  toggleFlip(e);
228
224
  }
229
- });
225
+ };
226
+
227
+ element.addEventListener('click', toggleFlip);
228
+ element.addEventListener('keydown', handleKeydown);
229
+
230
+ return {
231
+ destroy: () => {
232
+ element.removeEventListener('click', toggleFlip);
233
+ element.removeEventListener('keydown', handleKeydown);
234
+ }
235
+ };
230
236
  }
@@ -51,21 +51,14 @@ export function init(root, _options = {}) {
51
51
  const currentSlideId = NavigationState.getCurrentSlideId();
52
52
  const imageId = container.id || `interactive-image-${Math.random().toString(36).substr(2, 9)}`;
53
53
  const accordionId = container.dataset.accordionId;
54
-
55
- // Register with EngagementManager if on a slide
56
- if (currentSlideId) {
57
- const hotspotIds = hotspots.map(h => h.dataset.hotspotId);
58
- if (typeof engagementManager.registerInteractiveImage === 'function') {
59
- engagementManager.registerInteractiveImage(currentSlideId, hotspotIds);
60
- }
61
- }
54
+ const cleanups = [];
62
55
 
63
56
  // Setup Accordion Integration if ID is provided
64
57
  let accordionElement = null;
65
58
  if (accordionId) {
66
59
  accordionElement = document.getElementById(accordionId);
67
60
  if (accordionElement) {
68
- setupAccordionIntegration(container, hotspots, accordionElement, accordionId);
61
+ cleanups.push(setupAccordionIntegration(hotspots, accordionElement, accordionId));
69
62
  } else {
70
63
  logger.warn(`[InteractiveImage] Accordion #${accordionId} not found for image ${imageId}`);
71
64
  }
@@ -113,21 +106,30 @@ export function init(root, _options = {}) {
113
106
  }
114
107
  };
115
108
 
116
- hotspot.addEventListener('click', handleClick);
117
- hotspot.addEventListener('keydown', (e) => {
109
+ const handleKeydown = (e) => {
118
110
  if (e.key === 'Enter' || e.key === ' ') {
119
111
  handleClick(e);
120
112
  }
113
+ };
114
+
115
+ hotspot.addEventListener('click', handleClick);
116
+ hotspot.addEventListener('keydown', handleKeydown);
117
+ cleanups.push(() => {
118
+ hotspot.removeEventListener('click', handleClick);
119
+ hotspot.removeEventListener('keydown', handleKeydown);
121
120
  });
122
121
  });
123
122
 
124
123
  logger.debug(`[InteractiveImage] Initialized ${hotspots.length} hotspots for ${imageId}`);
124
+ return {
125
+ destroy: () => cleanups.forEach(cleanup => cleanup())
126
+ };
125
127
  }
126
128
 
127
129
  /**
128
130
  * Sets up bi-directional sync between image hotspots and accordion panels.
129
131
  */
130
- function setupAccordionIntegration(imageContainer, hotspots, accordionElement, accordionId) {
132
+ function setupAccordionIntegration(hotspots, accordionElement, accordionId) {
131
133
  // Map hotspots by ID for quick access
132
134
  const hotspotMap = new Map(hotspots.map(h => [h.dataset.hotspotId, h]));
133
135
 
@@ -156,25 +158,23 @@ function setupAccordionIntegration(imageContainer, hotspots, accordionElement, a
156
158
 
157
159
  // 2. Hover Effects (Accordion Button -> Image Hotspot)
158
160
  const buttons = accordionElement.querySelectorAll('[data-panel]');
161
+ const buttonCleanups = [];
159
162
  buttons.forEach(btn => {
160
163
  const panelId = btn.dataset.panel;
161
164
  const hotspot = hotspotMap.get(panelId);
162
165
 
163
166
  if (hotspot) {
164
- btn.addEventListener('mouseenter', () => {
165
- hotspot.classList.add('highlighted');
166
- });
167
-
168
- btn.addEventListener('mouseleave', () => {
169
- hotspot.classList.remove('highlighted');
170
- });
171
-
172
- btn.addEventListener('focus', () => {
173
- hotspot.classList.add('highlighted');
174
- });
175
-
176
- btn.addEventListener('blur', () => {
177
- hotspot.classList.remove('highlighted');
167
+ const highlight = () => hotspot.classList.add('highlighted');
168
+ const unhighlight = () => hotspot.classList.remove('highlighted');
169
+ btn.addEventListener('mouseenter', highlight);
170
+ btn.addEventListener('mouseleave', unhighlight);
171
+ btn.addEventListener('focus', highlight);
172
+ btn.addEventListener('blur', unhighlight);
173
+ buttonCleanups.push(() => {
174
+ btn.removeEventListener('mouseenter', highlight);
175
+ btn.removeEventListener('mouseleave', unhighlight);
176
+ btn.removeEventListener('focus', highlight);
177
+ btn.removeEventListener('blur', unhighlight);
178
178
  });
179
179
  }
180
180
  });
@@ -196,40 +196,16 @@ function setupAccordionIntegration(imageContainer, hotspots, accordionElement, a
196
196
  }
197
197
  };
198
198
 
199
- // Component lifecycle cleanup using MutationObserver
200
- // When the container is removed from DOM, we automatically clean up all listeners
201
199
  const cleanupListeners = () => {
202
200
  document.removeEventListener('click', safeHandleOutsideClick);
203
201
  eventBus.off('accordion:toggled', handleAccordionToggle);
204
- if (observer) {
205
- observer.disconnect();
206
- }
202
+ buttonCleanups.forEach(cleanup => cleanup());
207
203
  };
208
204
 
209
205
  const safeHandleOutsideClick = (e) => {
210
- if (!document.body.contains(imageContainer)) {
211
- cleanupListeners();
212
- return;
213
- }
214
206
  handleOutsideClick(e);
215
207
  };
216
208
 
217
- // Use MutationObserver to detect when container is removed from DOM
218
- const observer = new MutationObserver((mutations) => {
219
- for (const mutation of mutations) {
220
- for (const node of mutation.removedNodes) {
221
- if (node === imageContainer || node.contains?.(imageContainer)) {
222
- cleanupListeners();
223
- return;
224
- }
225
- }
226
- }
227
- });
228
-
229
- // Observe parent for child removals (handles SPA navigation cleanup)
230
- if (imageContainer.parentNode) {
231
- observer.observe(imageContainer.parentNode, { childList: true });
232
- }
233
-
234
209
  document.addEventListener('click', safeHandleOutsideClick);
210
+ return cleanupListeners;
235
211
  }
@@ -65,7 +65,7 @@ export const schema = {
65
65
  export const metadata = {
66
66
  category: 'ui-component',
67
67
  cssFile: 'components/lightbox.css',
68
- engagementTracking: 'viewAllLightbox',
68
+ engagementTracking: 'viewAllLightboxes',
69
69
  emitsEvents: ['lightbox:opened', 'lightbox:closed']
70
70
  };
71
71
 
@@ -75,6 +75,7 @@ import { logger } from '../../utilities/logger.js';
75
75
  import { escapeHTML } from '../../utilities/utilities.js';
76
76
  import { fetchAndRenderMarkdown } from '../../utilities/markdown-renderer.js';
77
77
  import engagementManager from '../../engagement/engagement-manager.js';
78
+ import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
78
79
 
79
80
  // Lightbox state
80
81
  let lightboxElement = null;
@@ -91,6 +92,8 @@ let currentMediaType = 'image'; // 'image' | 'video' | 'markdown' | 'pdf'
91
92
  */
92
93
  function _resolvePath(src) {
93
94
  if (!src) return src;
95
+ const portableSrc = resolvePortableAssetUrl(src);
96
+ if (portableSrc !== src) return portableSrc;
94
97
  // Already absolute URL or protocol-relative
95
98
  if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')) {
96
99
  return src;
@@ -104,7 +107,7 @@ function _resolvePath(src) {
104
107
  return src;
105
108
  }
106
109
  // Otherwise, assume relative to course/ directory
107
- return `./course/${src}`;
110
+ return resolvePortableAssetUrl(`./course/${src}`);
108
111
  }
109
112
 
110
113
  /**
@@ -129,6 +132,7 @@ function getTriggerSrc(trigger) {
129
132
  */
130
133
  function detectPDF(url) {
131
134
  if (!url) return null;
135
+ if (url.startsWith('data:application/pdf')) return { type: 'pdf' };
132
136
  if (/\.pdf($|\?|#)/i.test(url)) return { type: 'pdf' };
133
137
  return null;
134
138
  }
@@ -140,6 +144,7 @@ function detectPDF(url) {
140
144
  */
141
145
  function detectMarkdown(url) {
142
146
  if (!url) return null;
147
+ if (url.startsWith('data:text/markdown')) return { type: 'markdown' };
143
148
  if (/\.md($|\?|#)/i.test(url)) return { type: 'markdown' };
144
149
  return null;
145
150
  }
@@ -182,6 +187,7 @@ function detectVimeo(url) {
182
187
  */
183
188
  function detectNativeVideo(url) {
184
189
  if (!url) return null;
190
+ if (url.startsWith('data:video/')) return { type: 'native' };
185
191
  const videoExtensions = /\.(mp4|webm|ogg|mov|m4v)($|\?)/i;
186
192
  if (videoExtensions.test(url)) return { type: 'native' };
187
193
  return null;
@@ -211,10 +217,11 @@ export function init(element) {
211
217
  createLightboxElement();
212
218
  }
213
219
 
214
- element.addEventListener('click', (e) => {
220
+ const handleClick = (e) => {
215
221
  e.preventDefault();
216
222
  openFromTrigger(element);
217
- });
223
+ };
224
+ element.addEventListener('click', handleClick);
218
225
 
219
226
  const src = getTriggerSrc(element);
220
227
  const mediaType = getMediaType(src);
@@ -239,6 +246,14 @@ export function init(element) {
239
246
  if (subtitle) {
240
247
  wrapTriggerWithSubtitle(element, subtitle);
241
248
  }
249
+
250
+ return {
251
+ destroy: () => {
252
+ element.removeEventListener('click', handleClick);
253
+ delete element.dataset.lightboxInitialized;
254
+ if (isOpen) close();
255
+ }
256
+ };
242
257
  }
243
258
 
244
259
  /**
@@ -649,4 +664,3 @@ function navigate(direction) {
649
664
  export function isVisible() {
650
665
  return isOpen;
651
666
  }
652
-
@@ -40,6 +40,7 @@ import * as NavigationState from '../../navigation/NavigationState.js';
40
40
  import * as AudioPlayer from './audio-player.js';
41
41
  import { eventBus } from '../../core/event-bus.js';
42
42
  import { logger } from '../../utilities/logger.js';
43
+ import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
43
44
  import { renderCompactPlayer } from './audio-player.js';
44
45
 
45
46
  // Schema for validation, linting, and AI-assisted authoring
@@ -144,7 +145,7 @@ export function setup() {
144
145
  * @param {HTMLElement} trigger
145
146
  */
146
147
  export function init(trigger) {
147
- trigger.addEventListener('click', () => {
148
+ const handleClick = () => {
148
149
  const title = trigger.dataset.title || 'Modal';
149
150
  let body = trigger.dataset.body || '';
150
151
  let footer = trigger.dataset.footer || '<button class="btn btn-secondary" data-action="close-modal">Close</button>';
@@ -183,7 +184,7 @@ export function init(trigger) {
183
184
  src: audioSrc,
184
185
  autoplay: trigger.dataset.audioAutoplay === 'true',
185
186
  required: trigger.dataset.audioRequired === 'true',
186
- completionThreshold: parseFloat(trigger.dataset.audioThreshold) || 0.95
187
+ completionThreshold: normalizeCompletionThreshold(trigger.dataset.audioThreshold)
187
188
  } : null;
188
189
 
189
190
  // Get modal ID for tracking (from trigger id or generate one)
@@ -200,7 +201,15 @@ export function init(trigger) {
200
201
  closeOnEscape: trigger.dataset.closeOnEscape !== 'false'
201
202
  }
202
203
  });
203
- });
204
+ };
205
+
206
+ trigger.addEventListener('click', handleClick);
207
+ return {
208
+ destroy: () => {
209
+ trigger.removeEventListener('click', handleClick);
210
+ if (isVisible()) hide();
211
+ }
212
+ };
204
213
  }
205
214
 
206
215
  /**
@@ -275,13 +284,15 @@ export async function show({ title, body, footer = '', modalId = null, config =
275
284
  // (enforced by runtime-linter - a slide cannot have both)
276
285
  if (audioManager.isReady() && audio && audio.src) {
277
286
  const audioContextId = `modal-${currentModalId}`;
287
+ let modalAudioLoaded = false;
278
288
  try {
279
289
  await audioManager.load({
280
290
  src: audio.src,
281
291
  autoplay: audio.autoplay === true,
282
292
  required: audio.required || false,
283
- completionThreshold: audio.completionThreshold || 0.95
293
+ completionThreshold: normalizeCompletionThreshold(audio.completionThreshold)
284
294
  }, audioContextId, 'modal');
295
+ modalAudioLoaded = true;
285
296
 
286
297
  logger.debug(`[Modal] Loaded modal audio: ${audioContextId}`);
287
298
 
@@ -290,11 +301,13 @@ export async function show({ title, body, footer = '', modalId = null, config =
290
301
  AudioPlayer.initAudioControlsInContainer(modalFooter);
291
302
  }
292
303
  } catch (err) {
293
- logger.warn('[Modal] Failed to load modal audio:', err.message);
304
+ if (err.name !== 'AbortError') {
305
+ logger.warn('[Modal] Failed to load modal audio:', err.message);
306
+ }
294
307
  }
295
308
 
296
309
  // If audio is required, listen for completion
297
- if (audio.required) {
310
+ if (audio.required && modalAudioLoaded) {
298
311
  audioCompletedHandler = ({ contextId }) => {
299
312
  if (contextId === audioContextId && currentSlideId) {
300
313
  engagementManager.trackModalAudioComplete(currentSlideId, currentModalId);
@@ -383,4 +396,4 @@ export function hide() {
383
396
  activeConfig = {};
384
397
 
385
398
  announceToScreenReader('Modal closed');
386
- }
399
+ }
@@ -60,16 +60,21 @@ export function setup() {
60
60
  * Initializes declarative notification triggers in a container using event delegation.
61
61
  * @param {HTMLElement} container
62
62
  */
63
- export function init(container) {
63
+ export function initNotificationTriggers(container) {
64
64
  // Use event delegation to handle dynamically rendered content
65
- container.addEventListener('click', (event) => {
65
+ const handleClick = (event) => {
66
66
  const trigger = event.target.closest('[data-action="show-notification"]');
67
67
  if (trigger) {
68
68
  const type = trigger.dataset.type || 'info';
69
69
  const message = trigger.dataset.message || 'Notification';
70
70
  showNotification(message, type);
71
71
  }
72
- });
72
+ };
73
+ container.addEventListener('click', handleClick);
74
+
75
+ return {
76
+ destroy: () => container.removeEventListener('click', handleClick)
77
+ };
73
78
  }
74
79
 
75
80
  export function showNotification(message, type = 'info', duration = 5000, options = {}) {
@@ -54,6 +54,8 @@ import engagementManager from '../../engagement/engagement-manager.js';
54
54
  import * as NavigationState from '../../navigation/NavigationState.js';
55
55
  import { logger } from '../../utilities/logger.js';
56
56
  import { iconManager } from '../../utilities/icons.js';
57
+ import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
58
+ import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
57
59
 
58
60
  /** @type {Map<string, VideoPlayer>} Active player instances */
59
61
  const playerInstances = new Map();
@@ -131,10 +133,12 @@ function detectExternalVideo(url) {
131
133
  * @returns {string} Resolved path
132
134
  */
133
135
  function resolvePath(src) {
136
+ const portableSrc = resolvePortableAssetUrl(src);
137
+ if (portableSrc !== src) return portableSrc;
134
138
  if (src.startsWith('http') || src.startsWith('/') || src.startsWith('./')) {
135
139
  return src;
136
140
  }
137
- return `./course/assets/${src}`;
141
+ return resolvePortableAssetUrl(`./course/assets/${src}`);
138
142
  }
139
143
 
140
144
  /**
@@ -148,7 +152,7 @@ class VideoPlayer {
148
152
  this.posterSrc = container.dataset.videoPoster;
149
153
  this.captionsSrc = container.dataset.videoCaptions;
150
154
  this.required = container.dataset.videoRequired === 'true';
151
- this.threshold = parseFloat(container.dataset.videoThreshold) || 0.95;
155
+ this.threshold = normalizeCompletionThreshold(container.dataset.videoThreshold);
152
156
  this.autoplay = container.dataset.videoAutoplay === 'true';
153
157
 
154
158
  if (!this.videoId) {
@@ -167,6 +171,18 @@ class VideoPlayer {
167
171
  this.iframe = null; // External video iframe
168
172
  this.elements = {};
169
173
  this.eventHandlers = {};
174
+ this.domEventHandlers = {
175
+ click: this._handleClick.bind(this),
176
+ progressKeydown: this._handleProgressKeydown.bind(this),
177
+ progressMousedown: this._handleProgressMousedown.bind(this),
178
+ fullscreenChange: this._handleFullscreenChange.bind(this),
179
+ videoDoubleClick: () => this._toggleFullscreen(),
180
+ overlayClick: (event) => {
181
+ event.stopPropagation();
182
+ this._togglePlayPause();
183
+ }
184
+ };
185
+ this.dragCleanup = null;
170
186
  this.isFullscreen = false;
171
187
 
172
188
  this._render();
@@ -311,35 +327,32 @@ class VideoPlayer {
311
327
  // External videos use platform's native controls
312
328
  if (this.isExternal) {
313
329
  // Only fullscreen detection for wrapper
314
- document.addEventListener('fullscreenchange', this._handleFullscreenChange.bind(this));
315
- document.addEventListener('webkitfullscreenchange', this._handleFullscreenChange.bind(this));
330
+ document.addEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
331
+ document.addEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
316
332
  return;
317
333
  }
318
334
 
319
335
  // Control bar click delegation
320
- this.container.addEventListener('click', this._handleClick.bind(this));
336
+ this.container.addEventListener('click', this.domEventHandlers.click);
321
337
 
322
338
  // Progress bar interactions
323
339
  if (this.elements.progressBar) {
324
- this.elements.progressBar.addEventListener('keydown', this._handleProgressKeydown.bind(this));
325
- this.elements.progressBar.addEventListener('mousedown', this._handleProgressMousedown.bind(this));
340
+ this.elements.progressBar.addEventListener('keydown', this.domEventHandlers.progressKeydown);
341
+ this.elements.progressBar.addEventListener('mousedown', this.domEventHandlers.progressMousedown);
326
342
  }
327
343
 
328
344
  // Fullscreen change detection
329
- document.addEventListener('fullscreenchange', this._handleFullscreenChange.bind(this));
330
- document.addEventListener('webkitfullscreenchange', this._handleFullscreenChange.bind(this));
345
+ document.addEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
346
+ document.addEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
331
347
 
332
348
  // Double-click video to toggle fullscreen
333
349
  if (this.video) {
334
- this.video.addEventListener('dblclick', () => this._toggleFullscreen());
350
+ this.video.addEventListener('dblclick', this.domEventHandlers.videoDoubleClick);
335
351
  }
336
352
 
337
353
  // Click overlay to play
338
354
  if (this.elements.overlay) {
339
- this.elements.overlay.addEventListener('click', (e) => {
340
- e.stopPropagation();
341
- this._togglePlayPause();
342
- });
355
+ this.elements.overlay.addEventListener('click', this.domEventHandlers.overlayClick);
343
356
  }
344
357
  }
345
358
 
@@ -447,8 +460,11 @@ class VideoPlayer {
447
460
  _handleProgressMousedown(event) {
448
461
  if (!this.video || !this.video.duration) return;
449
462
 
463
+ this.dragCleanup?.();
464
+
450
465
  const progressBar = this.elements.progressBar;
451
466
  const rect = progressBar.getBoundingClientRect();
467
+ if (rect.width <= 0) return;
452
468
 
453
469
  const seek = (clientX) => {
454
470
  const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
@@ -458,15 +474,19 @@ class VideoPlayer {
458
474
  seek(event.clientX);
459
475
 
460
476
  const onMouseMove = (e) => seek(e.clientX);
461
- const onMouseUp = () => {
477
+ const cleanupDrag = () => {
462
478
  document.removeEventListener('mousemove', onMouseMove);
463
- document.removeEventListener('mouseup', onMouseUp);
479
+ document.removeEventListener('mouseup', cleanupDrag);
464
480
  progressBar.classList.remove('dragging');
481
+ if (this.dragCleanup === cleanupDrag) {
482
+ this.dragCleanup = null;
483
+ }
465
484
  };
466
485
 
467
486
  progressBar.classList.add('dragging');
468
487
  document.addEventListener('mousemove', onMouseMove);
469
- document.addEventListener('mouseup', onMouseUp);
488
+ document.addEventListener('mouseup', cleanupDrag);
489
+ this.dragCleanup = cleanupDrag;
470
490
  }
471
491
 
472
492
  _attachToManager() {
@@ -605,6 +625,8 @@ class VideoPlayer {
605
625
  }
606
626
 
607
627
  destroy() {
628
+ this.dragCleanup?.();
629
+
608
630
  // Unsubscribe from events
609
631
  eventBus.off('video:loaded', this.eventHandlers.loaded);
610
632
  eventBus.off('video:play', this.eventHandlers.play);
@@ -616,11 +638,15 @@ class VideoPlayer {
616
638
  // Detach from manager
617
639
  if (this.video) {
618
640
  videoManager.detachVideo(this.video);
641
+ this.video.removeEventListener('dblclick', this.domEventHandlers.videoDoubleClick);
619
642
  }
620
643
 
621
- // Remove fullscreen listeners
622
- document.removeEventListener('fullscreenchange', this._handleFullscreenChange);
623
- document.removeEventListener('webkitfullscreenchange', this._handleFullscreenChange);
644
+ this.container.removeEventListener('click', this.domEventHandlers.click);
645
+ this.elements.progressBar?.removeEventListener('keydown', this.domEventHandlers.progressKeydown);
646
+ this.elements.progressBar?.removeEventListener('mousedown', this.domEventHandlers.progressMousedown);
647
+ this.elements.overlay?.removeEventListener('click', this.domEventHandlers.overlayClick);
648
+ document.removeEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
649
+ document.removeEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
624
650
 
625
651
  playerInstances.delete(this.videoId);
626
652
  logger.debug(`[VideoPlayer] Destroyed: ${this.videoId}`);