coursecode 0.1.0

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 (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. package/template/vite.config.js +339 -0
@@ -0,0 +1,1193 @@
1
+ /**
2
+ * @file audio-player.js
3
+ * @description Audio player UI component for course narration.
4
+ *
5
+ * Three usage modes:
6
+ * 1. Slide-level: Renders in navigation footer via #audio-player element (auto-managed)
7
+ * 2. Modal: Compact controls in modal footer via renderCompactPlayer()
8
+ * 3. Standalone: Inline via data-component="audio-player" (author-placed, supports gating)
9
+ *
10
+ * Standalone Usage:
11
+ * <div data-component="audio-player"
12
+ * data-audio-id="intro-narration"
13
+ * data-audio-src="audio/intro.mp3"
14
+ * data-audio-compact="false">
15
+ * </div>
16
+ *
17
+ * Engagement config for gating (three distinct types):
18
+ * - Slide audio: { type: 'slideAudioComplete', message: '...' }
19
+ * - Standalone audio: { type: 'audioComplete', audioId: 'intro-narration', message: '...' }
20
+ * - Modal audio: { type: 'modalAudioComplete', modalId: 'details-modal', message: '...' }
21
+ *
22
+ * Controls:
23
+ * - Play/Pause toggle
24
+ * - Restart (back to beginning)
25
+ * - Progress bar (clickable for seeking) - full mode only
26
+ * - Mute toggle
27
+ * - Current time / Duration display - full mode only
28
+ *
29
+ * @author Framework
30
+ * @version 2.0.0
31
+ */
32
+
33
+ export const schema = {
34
+ type: 'audio-player',
35
+ description: 'Audio player with progress bar and controls',
36
+ example: `<div data-component="audio-player" data-audio-id="intro-narration" data-audio-src="audio/intro.mp3">
37
+ <p style="color: #64748b; font-size: 0.875rem; font-style: italic;">🎧 Audio player renders dynamically with play/pause, progress bar, and mute controls.</p>
38
+ </div>`,
39
+ properties: {
40
+ audioId: { type: 'string', required: true, dataAttribute: 'data-audio-id' },
41
+ audioSrc: { type: 'string', required: true, dataAttribute: 'data-audio-src' },
42
+ compact: { type: 'boolean', default: false, dataAttribute: 'data-audio-compact' }
43
+ },
44
+ structure: {
45
+ container: '[data-component="audio-player"]',
46
+ children: {} // Content is dynamically rendered
47
+ }
48
+ };
49
+
50
+ export const metadata = {
51
+ category: 'ui-component',
52
+ cssFile: 'components/audio-player.css',
53
+ engagementTracking: 'audioComplete',
54
+ emitsEvents: ['audio:complete']
55
+ };
56
+
57
+ import { eventBus } from '../../core/event-bus.js';
58
+ import audioManager from '../../managers/audio-manager.js';
59
+ import engagementManager from '../../engagement/engagement-manager.js';
60
+ import * as NavigationState from '../../navigation/NavigationState.js';
61
+ import { logger } from '../../utilities/logger.js';
62
+ import { iconManager } from '../../utilities/icons.js';
63
+
64
+
65
+ /** @type {HTMLElement|null} */
66
+ let playerContainer = null;
67
+
68
+ /** @type {boolean} */
69
+ let isInitialized = false;
70
+
71
+ /** @type {boolean} Track if audio has ended (for reset button state) */
72
+ let hasEnded = false;
73
+
74
+ /** @type {Object} DOM element references */
75
+ const elements = {
76
+ playPauseBtn: null,
77
+ restartBtn: null,
78
+ muteBtn: null,
79
+ progressBar: null,
80
+ progressFill: null,
81
+ progressHandle: null,
82
+ timeDisplay: null
83
+ };
84
+
85
+ /**
86
+ * Initializes the audio player UI.
87
+ * Finds the container element and sets up event listeners.
88
+ */
89
+ export function setup() {
90
+ if (isInitialized) {
91
+ logger.warn('[AudioPlayer] Already initialized');
92
+ return;
93
+ }
94
+
95
+ playerContainer = document.getElementById('audio-player');
96
+ if (!playerContainer) {
97
+ logger.debug('[AudioPlayer] No #audio-player element found - audio UI disabled');
98
+ return;
99
+ }
100
+
101
+ // Render the player HTML
102
+ _renderPlayer();
103
+
104
+ // Cache element references
105
+ _cacheElements();
106
+
107
+ // Set up event listeners
108
+ _setupEventListeners();
109
+
110
+ // Subscribe to audio manager events
111
+ _subscribeToAudioEvents();
112
+
113
+ // Initial state: hidden (no audio loaded)
114
+ hide();
115
+
116
+ isInitialized = true;
117
+ logger.debug('[AudioPlayer] Initialized');
118
+ }
119
+
120
+ /**
121
+ * Renders the audio player HTML structure.
122
+ * @private
123
+ */
124
+ function _renderPlayer() {
125
+ playerContainer.innerHTML = `
126
+ <div class="audio-player-controls" role="group" aria-label="Audio narration controls">
127
+ <!-- Play/Pause/Reset Button -->
128
+ <button
129
+ type="button"
130
+ class="audio-btn audio-btn-play"
131
+ aria-label="Play audio"
132
+ data-action="audio-play-pause"
133
+ data-testid="audio-play-pause"
134
+ >
135
+ <span class="audio-icon audio-icon-play" aria-hidden="true">${iconManager.getIcon('play')}</span>
136
+ <span class="audio-icon audio-icon-pause" aria-hidden="true" style="display:none;">${iconManager.getIcon('pause')}</span>
137
+ <span class="audio-icon audio-icon-reset" aria-hidden="true" style="display:none;">${iconManager.getIcon('rotate-ccw')}</span>
138
+ </button>
139
+
140
+ <!-- Restart Button -->
141
+ <button
142
+ type="button"
143
+ class="audio-btn audio-btn-restart"
144
+ aria-label="Restart audio from beginning"
145
+ data-action="audio-restart"
146
+ data-testid="audio-restart"
147
+ >
148
+ <span aria-hidden="true">${iconManager.getIcon('rotate-ccw')}</span>
149
+ </button>
150
+
151
+ <!-- Progress Bar -->
152
+ <div
153
+ class="audio-progress-container"
154
+ role="slider"
155
+ aria-label="Audio progress"
156
+ aria-valuemin="0"
157
+ aria-valuemax="100"
158
+ aria-valuenow="0"
159
+ tabindex="0"
160
+ data-action="audio-seek"
161
+ data-testid="audio-progress"
162
+ >
163
+ <div class="audio-progress-track">
164
+ <div class="audio-progress-fill"></div>
165
+ <div class="audio-progress-handle"></div>
166
+ </div>
167
+ </div>
168
+
169
+ <!-- Time Display -->
170
+ <span class="audio-time" aria-live="off" data-testid="audio-time">
171
+ <span class="audio-time-current">0:00</span>
172
+ <span class="audio-time-separator">/</span>
173
+ <span class="audio-time-duration">0:00</span>
174
+ </span>
175
+
176
+ <!-- Mute Button -->
177
+ <button
178
+ type="button"
179
+ class="audio-btn audio-btn-mute"
180
+ aria-label="Mute audio"
181
+ data-action="audio-mute"
182
+ data-testid="audio-mute"
183
+ >
184
+ <span class="audio-icon audio-icon-unmuted" aria-hidden="true">${iconManager.getIcon('volume-2')}</span>
185
+ <span class="audio-icon audio-icon-muted" aria-hidden="true" style="display:none;">${iconManager.getIcon('volume-x')}</span>
186
+ </button>
187
+ </div>
188
+ `;
189
+ }
190
+
191
+ /**
192
+ * Renders a compact audio player HTML structure (for use in modals).
193
+ * Only includes play/pause, restart, and mute buttons - no progress bar or time display.
194
+ * @returns {string} HTML string for compact audio player
195
+ */
196
+ export function renderCompactPlayer() {
197
+ return `
198
+ <div class="audio-player-controls audio-player-compact audio-player-modal" role="group" aria-label="Audio narration controls">
199
+ <!-- Play/Pause Button -->
200
+ <button
201
+ type="button"
202
+ class="audio-btn audio-btn-play"
203
+ aria-label="Play audio"
204
+ data-action="audio-play-pause"
205
+ data-testid="audio-play-pause-compact"
206
+ >
207
+ <span class="audio-icon audio-icon-play" aria-hidden="true">${iconManager.getIcon('play')}</span>
208
+ <span class="audio-icon audio-icon-pause" aria-hidden="true" style="display:none;">${iconManager.getIcon('pause')}</span>
209
+ </button>
210
+
211
+ <!-- Restart Button -->
212
+ <button
213
+ type="button"
214
+ class="audio-btn audio-btn-restart"
215
+ aria-label="Restart audio from beginning"
216
+ data-action="audio-restart"
217
+ data-testid="audio-restart-compact"
218
+ >
219
+ <span aria-hidden="true">${iconManager.getIcon('rotate-ccw')}</span>
220
+ </button>
221
+
222
+ <!-- Mute Button -->
223
+ <button
224
+ type="button"
225
+ class="audio-btn audio-btn-mute"
226
+ aria-label="Mute audio"
227
+ data-action="audio-mute"
228
+ data-testid="audio-mute-compact"
229
+ >
230
+ <span class="audio-icon audio-icon-unmuted" aria-hidden="true">${iconManager.getIcon('volume-2')}</span>
231
+ <span class="audio-icon audio-icon-muted" aria-hidden="true" style="display:none;">${iconManager.getIcon('volume-x')}</span>
232
+ </button>
233
+ </div>
234
+ `;
235
+ }
236
+
237
+ /**
238
+ * Caches DOM element references for performance.
239
+ * @private
240
+ */
241
+ function _cacheElements() {
242
+ elements.playPauseBtn = playerContainer.querySelector('[data-action="audio-play-pause"]');
243
+ elements.restartBtn = playerContainer.querySelector('[data-action="audio-restart"]');
244
+ elements.muteBtn = playerContainer.querySelector('[data-action="audio-mute"]');
245
+ elements.progressBar = playerContainer.querySelector('.audio-progress-container');
246
+ elements.progressFill = playerContainer.querySelector('.audio-progress-fill');
247
+ elements.progressHandle = playerContainer.querySelector('.audio-progress-handle');
248
+ elements.timeDisplay = playerContainer.querySelector('.audio-time');
249
+ elements.timeCurrent = playerContainer.querySelector('.audio-time-current');
250
+ elements.timeDuration = playerContainer.querySelector('.audio-time-duration');
251
+ }
252
+
253
+ /**
254
+ * Sets up event listeners for player controls.
255
+ * @private
256
+ */
257
+ function _setupEventListeners() {
258
+ // Delegated click handler
259
+ playerContainer.addEventListener('click', _handleClick);
260
+
261
+ // Progress bar keyboard navigation
262
+ elements.progressBar?.addEventListener('keydown', _handleProgressKeydown);
263
+
264
+ // Progress bar mouse interaction
265
+ elements.progressBar?.addEventListener('mousedown', _handleProgressMousedown);
266
+ }
267
+
268
+ /**
269
+ * Handles click events on player controls.
270
+ * @private
271
+ * @param {Event} event
272
+ */
273
+ function _handleClick(event) {
274
+ const target = event.target.closest('[data-action]');
275
+ if (!target) return;
276
+
277
+ const action = target.dataset.action;
278
+
279
+ switch (action) {
280
+ case 'audio-play-pause':
281
+ // If audio has ended, restart and play from beginning
282
+ if (hasEnded) {
283
+ audioManager.restart();
284
+ hasEnded = false;
285
+ audioManager.play().catch(err => {
286
+ logger.warn('[AudioPlayer] Play after restart failed:', err.message);
287
+ });
288
+ } else {
289
+ audioManager.togglePlayPause().catch(err => {
290
+ logger.warn('[AudioPlayer] Play failed:', err.message);
291
+ });
292
+ }
293
+ break;
294
+
295
+ case 'audio-restart':
296
+ audioManager.restart();
297
+ break;
298
+
299
+ case 'audio-mute':
300
+ audioManager.toggleMute();
301
+ break;
302
+
303
+ case 'audio-seek':
304
+ // Handled by mousedown for more precise seeking
305
+ break;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Handles keyboard navigation on progress bar.
311
+ * @private
312
+ * @param {KeyboardEvent} event
313
+ */
314
+ function _handleProgressKeydown(event) {
315
+ const state = audioManager.getState();
316
+ if (!state.duration) return;
317
+
318
+ let seekDelta = 0;
319
+
320
+ switch (event.key) {
321
+ case 'ArrowLeft':
322
+ seekDelta = -5; // 5 seconds back
323
+ break;
324
+ case 'ArrowRight':
325
+ seekDelta = 5; // 5 seconds forward
326
+ break;
327
+ case 'Home':
328
+ audioManager.seek(0);
329
+ event.preventDefault();
330
+ return;
331
+ case 'End':
332
+ audioManager.seek(state.duration);
333
+ event.preventDefault();
334
+ return;
335
+ default:
336
+ return;
337
+ }
338
+
339
+ event.preventDefault();
340
+ const newPosition = Math.max(0, Math.min(state.position + seekDelta, state.duration));
341
+ audioManager.seek(newPosition);
342
+ }
343
+
344
+ /**
345
+ * Handles mouse interaction on progress bar for seeking.
346
+ * @private
347
+ * @param {MouseEvent} event
348
+ */
349
+ function _handleProgressMousedown(event) {
350
+ if (!audioManager.hasAudio()) return;
351
+
352
+ const progressBar = elements.progressBar;
353
+ const rect = progressBar.getBoundingClientRect();
354
+
355
+ const seek = (clientX) => {
356
+ const percentage = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
357
+ audioManager.seekToPercentage(percentage);
358
+ };
359
+
360
+ // Initial seek on click
361
+ seek(event.clientX);
362
+
363
+ // Set up drag behavior
364
+ const onMouseMove = (e) => seek(e.clientX);
365
+ const onMouseUp = () => {
366
+ document.removeEventListener('mousemove', onMouseMove);
367
+ document.removeEventListener('mouseup', onMouseUp);
368
+ progressBar.classList.remove('dragging');
369
+ };
370
+
371
+ progressBar.classList.add('dragging');
372
+ document.addEventListener('mousemove', onMouseMove);
373
+ document.addEventListener('mouseup', onMouseUp);
374
+ }
375
+
376
+ /**
377
+ * Subscribes to audio manager events for UI updates.
378
+ * @private
379
+ */
380
+ function _subscribeToAudioEvents() {
381
+ // Show player immediately in loading state when SLIDE audio begins loading
382
+ // Only show for slide audio, not modal audio (modals have their own footer)
383
+ eventBus.on('audio:loadStart', ({ contextType }) => {
384
+ if (contextType === 'slide') {
385
+ show(true); // true = loading state
386
+ setControlsEnabled(false);
387
+ hasEnded = false; // Reset ended state for new audio
388
+ _setPlayingState(false); // Reset to paused state - new audio isn't playing yet
389
+ _updateProgress(0, 0); // Reset progress bar
390
+ }
391
+ });
392
+
393
+ // Transition from loading to loaded state (slide audio only)
394
+ eventBus.on('audio:loaded', ({ duration, contextType }) => {
395
+ if (contextType === 'slide') {
396
+ show(false); // false = fully loaded
397
+ setControlsEnabled(true);
398
+ _updateDuration(duration);
399
+ // Sync mute button state with audioManager (mute state persists across slides)
400
+ _setMutedState(audioManager.getState().isMuted);
401
+ }
402
+ });
403
+
404
+ // Hide player when SLIDE audio unloads
405
+ eventBus.on('audio:unloaded', ({ contextType }) => {
406
+ // Only hide if it was slide audio (check if player is visible first)
407
+ // Modal audio unload will just trigger hide anyway since player should already be hidden
408
+ if (contextType !== 'modal' || !isVisible()) {
409
+ hide();
410
+ }
411
+ });
412
+
413
+ // Update play/pause button state (slide audio only)
414
+ eventBus.on('audio:play', ({ contextId: _contextId }) => {
415
+ // Only update if current player is visible (means it's showing slide audio)
416
+ if (isVisible()) {
417
+ hasEnded = false; // Clear ended state when playing
418
+ _setPlayingState(true);
419
+ }
420
+ });
421
+
422
+ eventBus.on('audio:pause', ({ contextId: _contextId }) => {
423
+ if (isVisible()) {
424
+ _setPlayingState(false);
425
+ }
426
+ });
427
+
428
+ eventBus.on('audio:ended', () => {
429
+ hasEnded = true;
430
+ _setEndedState();
431
+ // Keep progress at 100% - provides completion feedback in course context
432
+ });
433
+
434
+ // Update progress bar
435
+ eventBus.on('audio:progress', ({ position, duration, percentage: _percentage }) => {
436
+ _updateProgress(position, duration);
437
+ });
438
+
439
+ // Update mute button state
440
+ eventBus.on('audio:stateChange', ({ state, reason }) => {
441
+ if (reason === 'volumechange') {
442
+ _setMutedState(state.isMuted);
443
+ }
444
+ });
445
+ }
446
+
447
+ /**
448
+ * Shows the audio player with optional loading state.
449
+ * @param {boolean} [loading=false] - If true, shows a loading skeleton
450
+ */
451
+ export function show(loading = false) {
452
+ if (!playerContainer) return;
453
+
454
+ playerContainer.hidden = false;
455
+ playerContainer.setAttribute('aria-hidden', 'false');
456
+
457
+ if (loading) {
458
+ playerContainer.classList.add('audio-player-loading');
459
+ } else {
460
+ playerContainer.classList.remove('audio-player-loading');
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Hides the audio player.
466
+ */
467
+ export function hide() {
468
+ if (playerContainer) {
469
+ playerContainer.hidden = true;
470
+ playerContainer.setAttribute('aria-hidden', 'true');
471
+ playerContainer.classList.remove('audio-player-loading');
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Enables/disables all audio player controls.
477
+ * @param {boolean} enabled
478
+ */
479
+ export function setControlsEnabled(enabled) {
480
+ if (!playerContainer) return;
481
+
482
+ const buttons = playerContainer.querySelectorAll('button');
483
+ const progressBar = playerContainer.querySelector('.audio-progress-container');
484
+
485
+ buttons.forEach(btn => {
486
+ btn.disabled = !enabled;
487
+ });
488
+
489
+ if (progressBar) {
490
+ if (enabled) {
491
+ progressBar.removeAttribute('aria-disabled');
492
+ } else {
493
+ progressBar.setAttribute('aria-disabled', 'true');
494
+ }
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Updates the play/pause button state.
500
+ * @private
501
+ * @param {boolean} isPlaying
502
+ */
503
+ function _setPlayingState(isPlaying) {
504
+ const btn = elements.playPauseBtn;
505
+ if (!btn) return;
506
+
507
+ const playIcon = btn.querySelector('.audio-icon-play');
508
+ const pauseIcon = btn.querySelector('.audio-icon-pause');
509
+ const resetIcon = btn.querySelector('.audio-icon-reset');
510
+
511
+ if (isPlaying) {
512
+ playIcon.style.display = 'none';
513
+ pauseIcon.style.display = '';
514
+ resetIcon.style.display = 'none';
515
+ btn.setAttribute('aria-label', 'Pause audio');
516
+ btn.classList.add('playing');
517
+ btn.classList.remove('ended');
518
+ } else {
519
+ playIcon.style.display = '';
520
+ pauseIcon.style.display = 'none';
521
+ resetIcon.style.display = 'none';
522
+ btn.setAttribute('aria-label', 'Play audio');
523
+ btn.classList.remove('playing');
524
+ btn.classList.remove('ended');
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Sets the button to ended/reset state.
530
+ * @private
531
+ */
532
+ function _setEndedState() {
533
+ const btn = elements.playPauseBtn;
534
+ if (!btn) return;
535
+
536
+ const playIcon = btn.querySelector('.audio-icon-play');
537
+ const pauseIcon = btn.querySelector('.audio-icon-pause');
538
+ const resetIcon = btn.querySelector('.audio-icon-reset');
539
+
540
+ playIcon.style.display = 'none';
541
+ pauseIcon.style.display = 'none';
542
+ resetIcon.style.display = '';
543
+ btn.setAttribute('aria-label', 'Restart audio');
544
+ btn.classList.remove('playing');
545
+ btn.classList.add('ended');
546
+ }
547
+
548
+ /**
549
+ * Updates the mute button state.
550
+ * @private
551
+ * @param {boolean} isMuted
552
+ */
553
+ function _setMutedState(isMuted) {
554
+ const btn = elements.muteBtn;
555
+ if (!btn) return;
556
+
557
+ const unmutedIcon = btn.querySelector('.audio-icon-unmuted');
558
+ const mutedIcon = btn.querySelector('.audio-icon-muted');
559
+
560
+ if (isMuted) {
561
+ unmutedIcon.style.display = 'none';
562
+ mutedIcon.style.display = '';
563
+ btn.setAttribute('aria-label', 'Unmute audio');
564
+ btn.classList.add('muted');
565
+ } else {
566
+ unmutedIcon.style.display = '';
567
+ mutedIcon.style.display = 'none';
568
+ btn.setAttribute('aria-label', 'Mute audio');
569
+ btn.classList.remove('muted');
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Updates the progress bar and time display.
575
+ * @private
576
+ * @param {number} position - Current position in seconds
577
+ * @param {number} duration - Total duration in seconds
578
+ */
579
+ function _updateProgress(position, duration) {
580
+ const percentage = duration > 0 ? (position / duration) * 100 : 0;
581
+
582
+ // Update progress bar
583
+ if (elements.progressFill) {
584
+ elements.progressFill.style.width = `${percentage}%`;
585
+ }
586
+ if (elements.progressHandle) {
587
+ elements.progressHandle.style.left = `${percentage}%`;
588
+ }
589
+ if (elements.progressBar) {
590
+ elements.progressBar.setAttribute('aria-valuenow', Math.round(percentage));
591
+ }
592
+
593
+ // Update time display
594
+ if (elements.timeCurrent) {
595
+ elements.timeCurrent.textContent = _formatTime(position);
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Updates the duration display.
601
+ * @private
602
+ * @param {number} duration - Duration in seconds
603
+ */
604
+ function _updateDuration(duration) {
605
+ if (elements.timeDuration) {
606
+ elements.timeDuration.textContent = _formatTime(duration);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Formats seconds as MM:SS or H:MM:SS.
612
+ * @private
613
+ * @param {number} seconds
614
+ * @returns {string}
615
+ */
616
+ function _formatTime(seconds) {
617
+ if (!seconds || !isFinite(seconds)) return '0:00';
618
+
619
+ const hrs = Math.floor(seconds / 3600);
620
+ const mins = Math.floor((seconds % 3600) / 60);
621
+ const secs = Math.floor(seconds % 60);
622
+
623
+ if (hrs > 0) {
624
+ return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
625
+ }
626
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
627
+ }
628
+
629
+ /**
630
+ * Checks if the player is currently visible.
631
+ * @returns {boolean}
632
+ */
633
+ export function isVisible() {
634
+ return playerContainer && !playerContainer.hidden;
635
+ }
636
+
637
+ /**
638
+ * Gets the player container element.
639
+ * @returns {HTMLElement|null}
640
+ */
641
+ export function getContainer() {
642
+ return playerContainer;
643
+ }
644
+
645
+ /**
646
+ * Initializes event delegation for audio controls in a specific container.
647
+ * This is called by the modal to set up listeners on dynamically injected audio controls.
648
+ * @param {HTMLElement} container - The container element with audio controls
649
+ */
650
+ export function initAudioControlsInContainer(container) {
651
+ if (!container) return;
652
+
653
+ // Set up event delegation for audio controls within this container
654
+ container.addEventListener('click', (event) => {
655
+ const target = event.target.closest('[data-action]');
656
+ if (!target) return;
657
+
658
+ const action = target.dataset.action;
659
+
660
+ switch (action) {
661
+ case 'audio-play-pause':
662
+ audioManager.togglePlayPause().catch(err => {
663
+ logger.warn('[AudioPlayer] Play failed:', err.message);
664
+ });
665
+ break;
666
+
667
+ case 'audio-restart':
668
+ audioManager.restart();
669
+ break;
670
+
671
+ case 'audio-mute':
672
+ audioManager.toggleMute();
673
+ break;
674
+ }
675
+ });
676
+
677
+ // Update UI state for this container's audio controls
678
+ _updateContainerAudioState(container);
679
+
680
+ // Subscribe to state changes to keep this container's controls in sync
681
+ const updateHandler = () => {
682
+ _updateContainerAudioState(container);
683
+ };
684
+
685
+ eventBus.on('audio:play', updateHandler);
686
+ eventBus.on('audio:pause', updateHandler);
687
+ eventBus.on('audio:stateChange', updateHandler);
688
+
689
+ // Store cleanup function on container for removal later
690
+ container._audioStateUpdateCleanup = () => {
691
+ eventBus.off('audio:play', updateHandler);
692
+ eventBus.off('audio:pause', updateHandler);
693
+ eventBus.off('audio:stateChange', updateHandler);
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Updates audio control UI state for a specific container.
699
+ * @private
700
+ * @param {HTMLElement} container - The container with audio controls
701
+ */
702
+ function _updateContainerAudioState(container) {
703
+ const state = audioManager.getState();
704
+
705
+ // Update play/pause button
706
+ const playPauseBtn = container.querySelector('[data-action="audio-play-pause"]');
707
+ if (playPauseBtn) {
708
+ const playIcon = playPauseBtn.querySelector('.audio-icon-play');
709
+ const pauseIcon = playPauseBtn.querySelector('.audio-icon-pause');
710
+
711
+ if (state.isPlaying) {
712
+ playIcon?.style && (playIcon.style.display = 'none');
713
+ pauseIcon?.style && (pauseIcon.style.display = '');
714
+ playPauseBtn.setAttribute('aria-label', 'Pause audio');
715
+ playPauseBtn.classList.add('playing');
716
+ } else {
717
+ playIcon?.style && (playIcon.style.display = '');
718
+ pauseIcon?.style && (pauseIcon.style.display = 'none');
719
+ playPauseBtn.setAttribute('aria-label', 'Play audio');
720
+ playPauseBtn.classList.remove('playing');
721
+ }
722
+ }
723
+
724
+ // Update mute button
725
+ const muteBtn = container.querySelector('[data-action="audio-mute"]');
726
+ if (muteBtn) {
727
+ const unmutedIcon = muteBtn.querySelector('.audio-icon-unmuted');
728
+ const mutedIcon = muteBtn.querySelector('.audio-icon-muted');
729
+
730
+ if (state.isMuted) {
731
+ unmutedIcon?.style && (unmutedIcon.style.display = 'none');
732
+ mutedIcon?.style && (mutedIcon.style.display = '');
733
+ muteBtn.setAttribute('aria-label', 'Unmute audio');
734
+ muteBtn.classList.add('muted');
735
+ } else {
736
+ unmutedIcon?.style && (unmutedIcon.style.display = '');
737
+ mutedIcon?.style && (mutedIcon.style.display = 'none');
738
+ muteBtn.setAttribute('aria-label', 'Mute audio');
739
+ muteBtn.classList.remove('muted');
740
+ }
741
+ }
742
+ }
743
+
744
+ // =============================================================================
745
+ // STANDALONE AUDIO PLAYER (data-component="audio-player")
746
+ // =============================================================================
747
+
748
+ /** @type {Map<string, StandaloneAudioPlayer>} Active standalone player instances */
749
+ const standaloneInstances = new Map();
750
+
751
+ /**
752
+ * Renders full audio player HTML with progress bar and time display.
753
+ * @param {string} audioId - The audio ID for test attributes
754
+ * @returns {string}
755
+ */
756
+ function renderFullPlayer(audioId) {
757
+ return `
758
+ <div class="audio-player-controls audio-player-standalone" role="group" aria-label="Audio narration controls">
759
+ <button type="button" class="audio-btn audio-btn-play" aria-label="Play audio"
760
+ data-action="audio-play-pause" data-testid="audio-play-pause-${audioId}">
761
+ <span class="audio-icon audio-icon-play" aria-hidden="true">${iconManager.getIcon('play')}</span>
762
+ <span class="audio-icon audio-icon-pause" aria-hidden="true" style="display:none;">${iconManager.getIcon('pause')}</span>
763
+ </button>
764
+ <button type="button" class="audio-btn audio-btn-restart" aria-label="Restart audio"
765
+ data-action="audio-restart" data-testid="audio-restart-${audioId}">
766
+ <span aria-hidden="true">${iconManager.getIcon('rotate-ccw')}</span>
767
+ </button>
768
+ <div class="audio-progress-container" role="slider" aria-label="Audio progress"
769
+ aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0"
770
+ data-action="audio-seek" data-testid="audio-progress-${audioId}">
771
+ <div class="audio-progress-track">
772
+ <div class="audio-progress-fill"></div>
773
+ <div class="audio-progress-handle"></div>
774
+ </div>
775
+ </div>
776
+ <span class="audio-time" aria-live="off" data-testid="audio-time-${audioId}">
777
+ <span class="audio-time-current">0:00</span>
778
+ <span class="audio-time-separator">/</span>
779
+ <span class="audio-time-duration">0:00</span>
780
+ </span>
781
+ <button type="button" class="audio-btn audio-btn-mute" aria-label="Mute audio"
782
+ data-action="audio-mute" data-testid="audio-mute-${audioId}">
783
+ <span class="audio-icon audio-icon-unmuted" aria-hidden="true">${iconManager.getIcon('volume-2')}</span>
784
+ <span class="audio-icon audio-icon-muted" aria-hidden="true" style="display:none;">${iconManager.getIcon('volume-x')}</span>
785
+ </button>
786
+ </div>
787
+ `;
788
+ }
789
+
790
+ /**
791
+ * Renders compact audio player HTML (play/pause, restart, mute only).
792
+ * @param {string} audioId - The audio ID for test attributes
793
+ * @returns {string}
794
+ */
795
+ function renderStandaloneCompactPlayer(audioId) {
796
+ return `
797
+ <div class="audio-player-controls audio-player-compact audio-player-standalone" role="group" aria-label="Audio narration controls">
798
+ <button type="button" class="audio-btn audio-btn-play" aria-label="Play audio"
799
+ data-action="audio-play-pause" data-testid="audio-play-pause-${audioId}">
800
+ <span class="audio-icon audio-icon-play" aria-hidden="true">${iconManager.getIcon('play')}</span>
801
+ <span class="audio-icon audio-icon-pause" aria-hidden="true" style="display:none;">${iconManager.getIcon('pause')}</span>
802
+ </button>
803
+ <button type="button" class="audio-btn audio-btn-restart" aria-label="Restart audio"
804
+ data-action="audio-restart" data-testid="audio-restart-${audioId}">
805
+ <span aria-hidden="true">${iconManager.getIcon('rotate-ccw')}</span>
806
+ </button>
807
+ <button type="button" class="audio-btn audio-btn-mute" aria-label="Mute audio"
808
+ data-action="audio-mute" data-testid="audio-mute-${audioId}">
809
+ <span class="audio-icon audio-icon-unmuted" aria-hidden="true">${iconManager.getIcon('volume-2')}</span>
810
+ <span class="audio-icon audio-icon-muted" aria-hidden="true" style="display:none;">${iconManager.getIcon('volume-x')}</span>
811
+ </button>
812
+ </div>
813
+ `;
814
+ }
815
+
816
+ /**
817
+ * Class representing a standalone audio player instance.
818
+ */
819
+ class StandaloneAudioPlayer {
820
+ constructor(container) {
821
+ this.container = container;
822
+ this.audioId = container.dataset.audioId;
823
+ this.audioSrc = container.dataset.audioSrc;
824
+ this.required = container.dataset.audioRequired === 'true';
825
+ this.compact = container.dataset.audioCompact === 'true';
826
+ this.threshold = parseFloat(container.dataset.audioThreshold) || 0.95;
827
+
828
+ if (!this.audioId) {
829
+ throw new Error('[AudioPlayer] Standalone player requires data-audio-id');
830
+ }
831
+ if (!this.audioSrc) {
832
+ throw new Error(`[AudioPlayer] Standalone player "${this.audioId}" requires data-audio-src`);
833
+ }
834
+
835
+ this.contextId = `standalone-${this.audioId}`;
836
+ this.isActive = false;
837
+ this.eventHandlers = {};
838
+ this.elements = {};
839
+
840
+ this._render();
841
+ this._cacheElements();
842
+ this._setupEventListeners();
843
+ this._subscribeToAudioEvents();
844
+
845
+ // Sync mute button with current global mute state
846
+ this._setMutedState(audioManager.getState().isMuted);
847
+
848
+ standaloneInstances.set(this.audioId, this);
849
+ logger.debug(`[AudioPlayer] Standalone initialized: ${this.audioId}`);
850
+ }
851
+
852
+ _render() {
853
+ this.container.innerHTML = this.compact
854
+ ? renderStandaloneCompactPlayer(this.audioId)
855
+ : renderFullPlayer(this.audioId);
856
+ }
857
+
858
+ _cacheElements() {
859
+ this.elements.playPauseBtn = this.container.querySelector('[data-action="audio-play-pause"]');
860
+ this.elements.restartBtn = this.container.querySelector('[data-action="audio-restart"]');
861
+ this.elements.muteBtn = this.container.querySelector('[data-action="audio-mute"]');
862
+ this.elements.progressBar = this.container.querySelector('.audio-progress-container');
863
+ this.elements.progressFill = this.container.querySelector('.audio-progress-fill');
864
+ this.elements.progressHandle = this.container.querySelector('.audio-progress-handle');
865
+ this.elements.timeCurrent = this.container.querySelector('.audio-time-current');
866
+ this.elements.timeDuration = this.container.querySelector('.audio-time-duration');
867
+ }
868
+
869
+ _setupEventListeners() {
870
+ this.container.addEventListener('click', this._handleClick.bind(this));
871
+
872
+ if (this.elements.progressBar) {
873
+ this.elements.progressBar.addEventListener('keydown', this._handleProgressKeydown.bind(this));
874
+ this.elements.progressBar.addEventListener('mousedown', this._handleProgressMousedown.bind(this));
875
+ }
876
+ }
877
+
878
+ _handleClick(event) {
879
+ const target = event.target.closest('[data-action]');
880
+ if (!target) return;
881
+
882
+ const action = target.dataset.action;
883
+
884
+ // If not active and trying to play, load first
885
+ if (!this.isActive && action === 'audio-play-pause') {
886
+ this._loadAndPlay();
887
+ return;
888
+ }
889
+
890
+ switch (action) {
891
+ case 'audio-play-pause':
892
+ audioManager.togglePlayPause().catch(err => {
893
+ logger.warn(`[AudioPlayer] Play failed for ${this.audioId}:`, err.message);
894
+ });
895
+ break;
896
+ case 'audio-restart':
897
+ if (this.isActive) {
898
+ audioManager.restart();
899
+ } else {
900
+ this._loadAndPlay();
901
+ }
902
+ break;
903
+ case 'audio-mute':
904
+ audioManager.toggleMute();
905
+ break;
906
+ }
907
+ }
908
+
909
+ async _loadAndPlay() {
910
+ try {
911
+ await audioManager.load({
912
+ src: this.audioSrc,
913
+ autoplay: true,
914
+ required: this.required,
915
+ completionThreshold: this.threshold
916
+ }, this.contextId, 'standalone');
917
+ } catch (error) {
918
+ logger.error(`[AudioPlayer] Failed to load ${this.audioId}:`, error.message);
919
+ }
920
+ }
921
+
922
+ _handleProgressKeydown(event) {
923
+ if (!this.isActive) return;
924
+ const state = audioManager.getState();
925
+ if (!state.duration) return;
926
+
927
+ let seekDelta = 0;
928
+ switch (event.key) {
929
+ case 'ArrowLeft': seekDelta = -5; break;
930
+ case 'ArrowRight': seekDelta = 5; break;
931
+ case 'Home': audioManager.seek(0); event.preventDefault(); return;
932
+ case 'End': audioManager.seek(state.duration); event.preventDefault(); return;
933
+ default: return;
934
+ }
935
+ event.preventDefault();
936
+ audioManager.seek(Math.max(0, Math.min(state.position + seekDelta, state.duration)));
937
+ }
938
+
939
+ _handleProgressMousedown(event) {
940
+ if (!this.isActive || !audioManager.hasAudio()) return;
941
+
942
+ const progressBar = this.elements.progressBar;
943
+ const rect = progressBar.getBoundingClientRect();
944
+
945
+ const seek = (clientX) => {
946
+ const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
947
+ audioManager.seekToPercentage(pct);
948
+ };
949
+
950
+ seek(event.clientX);
951
+
952
+ const onMouseMove = (e) => seek(e.clientX);
953
+ const onMouseUp = () => {
954
+ document.removeEventListener('mousemove', onMouseMove);
955
+ document.removeEventListener('mouseup', onMouseUp);
956
+ progressBar.classList.remove('dragging');
957
+ };
958
+
959
+ progressBar.classList.add('dragging');
960
+ document.addEventListener('mousemove', onMouseMove);
961
+ document.addEventListener('mouseup', onMouseUp);
962
+ }
963
+
964
+ _subscribeToAudioEvents() {
965
+ this.eventHandlers.loadStart = ({ contextId }) => {
966
+ if (contextId === this.contextId) {
967
+ this.isActive = true;
968
+ this.container.classList.add('audio-player-loading');
969
+ this._setControlsEnabled(false);
970
+ } else {
971
+ this.isActive = false;
972
+ this._resetUI();
973
+ }
974
+ };
975
+
976
+ this.eventHandlers.loaded = ({ contextId, duration }) => {
977
+ if (contextId === this.contextId) {
978
+ this.container.classList.remove('audio-player-loading');
979
+ this._setControlsEnabled(true);
980
+ this._updateDuration(duration);
981
+ // Sync mute button state with audioManager (mute state persists across slides)
982
+ this._setMutedState(audioManager.getState().isMuted);
983
+ }
984
+ };
985
+
986
+ this.eventHandlers.unloaded = () => {
987
+ this.isActive = false;
988
+ this._resetUI();
989
+ };
990
+
991
+ this.eventHandlers.play = ({ contextId }) => {
992
+ if (contextId === this.contextId) {
993
+ this._setPlayingState(true);
994
+ } else {
995
+ this._setPlayingState(false);
996
+ }
997
+ };
998
+
999
+ this.eventHandlers.pause = ({ contextId }) => {
1000
+ if (contextId === this.contextId) {
1001
+ this._setPlayingState(false);
1002
+ }
1003
+ };
1004
+
1005
+ this.eventHandlers.ended = ({ contextId }) => {
1006
+ if (contextId === this.contextId) {
1007
+ this._setPlayingState(false);
1008
+ // Keep progress at 100% - provides completion feedback in course context
1009
+ }
1010
+ };
1011
+
1012
+ this.eventHandlers.progress = ({ position, duration }) => {
1013
+ // Only update if this player's audio is active
1014
+ if (this.isActive && audioManager.getState().contextId === this.contextId) {
1015
+ this._updateProgress(position, duration);
1016
+ }
1017
+ };
1018
+
1019
+ this.eventHandlers.stateChange = ({ state, reason }) => {
1020
+ if (reason === 'volumechange') {
1021
+ this._setMutedState(state.isMuted);
1022
+ }
1023
+ };
1024
+
1025
+ this.eventHandlers.completed = ({ contextId }) => {
1026
+ if (contextId === this.contextId && this.required) {
1027
+ const currentSlideId = NavigationState.getCurrentSlideId();
1028
+ if (currentSlideId) {
1029
+ engagementManager.trackStandaloneAudioComplete(currentSlideId, this.audioId);
1030
+ }
1031
+ }
1032
+ };
1033
+
1034
+ eventBus.on('audio:loadStart', this.eventHandlers.loadStart);
1035
+ eventBus.on('audio:loaded', this.eventHandlers.loaded);
1036
+ eventBus.on('audio:unloaded', this.eventHandlers.unloaded);
1037
+ eventBus.on('audio:play', this.eventHandlers.play);
1038
+ eventBus.on('audio:pause', this.eventHandlers.pause);
1039
+ eventBus.on('audio:ended', this.eventHandlers.ended);
1040
+ eventBus.on('audio:progress', this.eventHandlers.progress);
1041
+ eventBus.on('audio:stateChange', this.eventHandlers.stateChange);
1042
+ eventBus.on('audio:completed', this.eventHandlers.completed);
1043
+ }
1044
+
1045
+ _resetUI() {
1046
+ this.container.classList.remove('audio-player-loading');
1047
+ this._setPlayingState(false);
1048
+ this._updateProgress(0, 0);
1049
+ if (this.elements.timeDuration) {
1050
+ this.elements.timeDuration.textContent = '0:00';
1051
+ }
1052
+ }
1053
+
1054
+ _setControlsEnabled(enabled) {
1055
+ this.container.querySelectorAll('button').forEach(btn => btn.disabled = !enabled);
1056
+ if (this.elements.progressBar) {
1057
+ this.elements.progressBar.setAttribute('aria-disabled', enabled ? 'false' : 'true');
1058
+ }
1059
+ }
1060
+
1061
+ _setPlayingState(isPlaying) {
1062
+ const btn = this.elements.playPauseBtn;
1063
+ if (!btn) return;
1064
+ const playIcon = btn.querySelector('.audio-icon-play');
1065
+ const pauseIcon = btn.querySelector('.audio-icon-pause');
1066
+
1067
+ if (isPlaying) {
1068
+ if (playIcon) playIcon.style.display = 'none';
1069
+ if (pauseIcon) pauseIcon.style.display = '';
1070
+ btn.setAttribute('aria-label', 'Pause audio');
1071
+ btn.classList.add('playing');
1072
+ } else {
1073
+ if (playIcon) playIcon.style.display = '';
1074
+ if (pauseIcon) pauseIcon.style.display = 'none';
1075
+ btn.setAttribute('aria-label', 'Play audio');
1076
+ btn.classList.remove('playing');
1077
+ }
1078
+ }
1079
+
1080
+ _setMutedState(isMuted) {
1081
+ const btn = this.elements.muteBtn;
1082
+ if (!btn) return;
1083
+ const unmutedIcon = btn.querySelector('.audio-icon-unmuted');
1084
+ const mutedIcon = btn.querySelector('.audio-icon-muted');
1085
+
1086
+ if (isMuted) {
1087
+ if (unmutedIcon) unmutedIcon.style.display = 'none';
1088
+ if (mutedIcon) mutedIcon.style.display = '';
1089
+ btn.setAttribute('aria-label', 'Unmute audio');
1090
+ btn.classList.add('muted');
1091
+ } else {
1092
+ if (unmutedIcon) unmutedIcon.style.display = '';
1093
+ if (mutedIcon) mutedIcon.style.display = 'none';
1094
+ btn.setAttribute('aria-label', 'Mute audio');
1095
+ btn.classList.remove('muted');
1096
+ }
1097
+ }
1098
+
1099
+ _updateProgress(position, duration) {
1100
+ const pct = duration > 0 ? (position / duration) * 100 : 0;
1101
+ if (this.elements.progressFill) this.elements.progressFill.style.width = `${pct}%`;
1102
+ if (this.elements.progressHandle) this.elements.progressHandle.style.left = `${pct}%`;
1103
+ if (this.elements.progressBar) this.elements.progressBar.setAttribute('aria-valuenow', Math.round(pct));
1104
+ if (this.elements.timeCurrent) this.elements.timeCurrent.textContent = _formatTime(position);
1105
+ }
1106
+
1107
+ _updateDuration(duration) {
1108
+ if (this.elements.timeDuration) {
1109
+ this.elements.timeDuration.textContent = _formatTime(duration);
1110
+ }
1111
+ }
1112
+
1113
+ destroy() {
1114
+ eventBus.off('audio:loadStart', this.eventHandlers.loadStart);
1115
+ eventBus.off('audio:loaded', this.eventHandlers.loaded);
1116
+ eventBus.off('audio:unloaded', this.eventHandlers.unloaded);
1117
+ eventBus.off('audio:play', this.eventHandlers.play);
1118
+ eventBus.off('audio:pause', this.eventHandlers.pause);
1119
+ eventBus.off('audio:ended', this.eventHandlers.ended);
1120
+ eventBus.off('audio:progress', this.eventHandlers.progress);
1121
+ eventBus.off('audio:stateChange', this.eventHandlers.stateChange);
1122
+ eventBus.off('audio:completed', this.eventHandlers.completed);
1123
+
1124
+ standaloneInstances.delete(this.audioId);
1125
+
1126
+ if (this.isActive && audioManager.hasAudio()) {
1127
+ audioManager.unload();
1128
+ }
1129
+
1130
+ logger.debug(`[AudioPlayer] Standalone destroyed: ${this.audioId}`);
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Initializes a single standalone audio player element.
1136
+ * Called by the UI initializer for each data-component="audio-player" element.
1137
+ * @param {HTMLElement} element - The audio player container element
1138
+ * @returns {StandaloneAudioPlayer|null} The initialized player or null on error
1139
+ */
1140
+ export function init(element) {
1141
+ try {
1142
+ return new StandaloneAudioPlayer(element);
1143
+ } catch (error) {
1144
+ logger.error('[AudioPlayer] Standalone init failed:', error.message);
1145
+ return null;
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Initializes all standalone audio players in a container.
1151
+ * Called by the declarative component system.
1152
+ * @param {HTMLElement} root - The root element to scan
1153
+ * @returns {StandaloneAudioPlayer[]} Array of initialized players
1154
+ */
1155
+ export function initStandaloneAudioPlayers(root) {
1156
+ const containers = root.querySelectorAll('[data-component="audio-player"]');
1157
+ const players = [];
1158
+
1159
+ containers.forEach(container => {
1160
+ const player = init(container);
1161
+ if (player) {
1162
+ players.push(player);
1163
+ }
1164
+ });
1165
+
1166
+ return players;
1167
+ }
1168
+
1169
+ /**
1170
+ * Destroys all active standalone audio player instances.
1171
+ * Called when navigating away from a slide.
1172
+ */
1173
+ export function destroyAllStandaloneAudioPlayers() {
1174
+ standaloneInstances.forEach(player => player.destroy());
1175
+ standaloneInstances.clear();
1176
+ }
1177
+
1178
+ /**
1179
+ * Gets an active standalone player by audio ID.
1180
+ * @param {string} audioId
1181
+ * @returns {StandaloneAudioPlayer|undefined}
1182
+ */
1183
+ export function getStandalonePlayer(audioId) {
1184
+ return standaloneInstances.get(audioId);
1185
+ }
1186
+
1187
+ /**
1188
+ * Gets all active standalone audio IDs on the current slide.
1189
+ * @returns {string[]}
1190
+ */
1191
+ export function getActiveStandaloneAudioIds() {
1192
+ return Array.from(standaloneInstances.keys());
1193
+ }