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,944 @@
1
+ /**
2
+ * @file audio-manager.js
3
+ * @description Singleton manager for audio playback in SCORM courses.
4
+ * Handles narration audio for slides, modals, and tabs with position persistence
5
+ * and completion tracking for gating requirements.
6
+ *
7
+ * Features:
8
+ * - Single audio instance (one narrator at a time)
9
+ * - Position persistence via stateManager
10
+ * - Completion tracking with configurable threshold
11
+ * - Event-based state communication
12
+ * - Accessibility support (mute state, keyboard controls)
13
+ *
14
+ * @author Framework
15
+ * @version 2.0.0
16
+ */
17
+
18
+ import { eventBus } from '../core/event-bus.js';
19
+ import stateManager from '../state/index.js';
20
+ import { logger } from '../utilities/logger.js';
21
+
22
+ /**
23
+ * @typedef {Object} AudioState
24
+ * @property {string|null} currentSrc - Current audio source URL
25
+ * @property {string|null} contextId - Current context (slideId, modalId, or tabId)
26
+ * @property {string} contextType - Type of context ('slide' | 'modal' | 'tab')
27
+ * @property {number} position - Current playback position in seconds
28
+ * @property {boolean} isPlaying - Whether audio is currently playing
29
+ * @property {boolean} isMuted - Whether audio is muted
30
+ * @property {number} duration - Total duration of current track
31
+ * @property {number} volume - Volume level (0-1)
32
+ * @property {boolean} required - Whether audio completion is required for gating
33
+ * @property {number} completionThreshold - Percentage (0-1) required for completion
34
+ * @property {boolean} isCompleted - Whether audio has reached completion threshold
35
+ */
36
+
37
+ /**
38
+ * @typedef {Object} AudioConfig
39
+ * @property {string} src - Audio file source path (relative to course/assets/)
40
+ * @property {boolean} [autoplay=false] - Whether to autoplay when loaded
41
+ * @property {boolean} [required=false] - Whether completion is required for gating
42
+ * @property {number} [completionThreshold=0.95] - Percentage (0-1) required for completion
43
+ */
44
+
45
+ /** Default completion threshold (95%) */
46
+ const DEFAULT_COMPLETION_THRESHOLD = 0.95;
47
+
48
+ class AudioManager {
49
+ constructor() {
50
+ /** @type {HTMLAudioElement|null} */
51
+ this.audio = null;
52
+
53
+ /** @type {AudioState} */
54
+ this.state = {
55
+ currentSrc: null,
56
+ contextId: null,
57
+ contextType: 'slide',
58
+ position: 0,
59
+ isPlaying: false,
60
+ isMuted: false,
61
+ duration: 0,
62
+ volume: 1,
63
+ required: false,
64
+ completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
65
+ isCompleted: false
66
+ };
67
+
68
+ /** @type {boolean} */
69
+ this.isInitialized = false;
70
+
71
+ /** @type {Map<string, number>} - Stores positions for each context */
72
+ this.positionCache = new Map();
73
+
74
+ /** @type {Map<string, boolean>} - Stores completion status for each context */
75
+ this.completionCache = new Map();
76
+
77
+ /** @type {number|null} */
78
+ this.updateInterval = null;
79
+
80
+ /** @type {number} - Tracks max position reached (handles seeks/replays) */
81
+ this.maxPositionReached = 0;
82
+
83
+ /** @type {boolean} - Flag to ignore errors during intentional source switches */
84
+ this._isSwitchingSource = false;
85
+
86
+ /** @type {Function|null} - Cleanup function for pending load operation */
87
+ this._pendingLoadCleanup = null;
88
+ }
89
+
90
+ /**
91
+ * Initializes the AudioManager. Must be called once during app startup.
92
+ * @throws {Error} If already initialized
93
+ */
94
+ initialize() {
95
+ if (this.isInitialized) {
96
+ logger.warn('[AudioManager] Already initialized');
97
+ return;
98
+ }
99
+
100
+ // Create the audio element
101
+ this.audio = new Audio();
102
+ this.audio.preload = 'metadata';
103
+
104
+ // Explicitly set muted to false (some browsers may default differently)
105
+ this.audio.muted = false;
106
+
107
+ // Set up audio event listeners
108
+ this._setupAudioListeners();
109
+
110
+ // Restore persisted state (may override muted based on user preference)
111
+ this._hydrateFromState();
112
+
113
+ this.isInitialized = true;
114
+ logger.debug('[AudioManager] Initialized');
115
+
116
+ eventBus.emit('audio:initialized');
117
+ }
118
+
119
+ /**
120
+ * Sets up event listeners on the audio element.
121
+ * @private
122
+ */
123
+ _setupAudioListeners() {
124
+ const audio = this.audio;
125
+
126
+ // Handle duration becoming available (fallback for streaming/chunked responses)
127
+ // Only emits if duration wasn't set by loadedmetadata
128
+ audio.addEventListener('durationchange', () => {
129
+ if (isFinite(audio.duration) && audio.duration > 0 && !this.state.duration) {
130
+ this.state.duration = audio.duration;
131
+ // Re-emit loaded event when we get a valid duration
132
+ eventBus.emit('audio:loaded', {
133
+ src: this.state.currentSrc,
134
+ duration: this.state.duration,
135
+ contextId: this.state.contextId,
136
+ contextType: this.state.contextType
137
+ });
138
+ }
139
+ });
140
+
141
+ audio.addEventListener('loadedmetadata', () => {
142
+ // Only set duration if it's a finite value
143
+ if (isFinite(audio.duration) && audio.duration > 0) {
144
+ this.state.duration = audio.duration;
145
+ }
146
+ this._emitStateChange('loaded');
147
+ eventBus.emit('audio:loaded', {
148
+ src: this.state.currentSrc,
149
+ duration: this.state.duration,
150
+ contextId: this.state.contextId,
151
+ contextType: this.state.contextType
152
+ });
153
+ });
154
+
155
+ audio.addEventListener('play', () => {
156
+ this.state.isPlaying = true;
157
+ this._startPositionUpdates();
158
+ this._emitStateChange('play');
159
+ eventBus.emit('audio:play', {
160
+ contextId: this.state.contextId,
161
+ contextType: this.state.contextType
162
+ });
163
+ });
164
+
165
+ audio.addEventListener('pause', () => {
166
+ this.state.isPlaying = false;
167
+ this._stopPositionUpdates();
168
+ this._savePosition();
169
+ this._emitStateChange('pause');
170
+ eventBus.emit('audio:pause', {
171
+ contextId: this.state.contextId,
172
+ contextType: this.state.contextType,
173
+ position: this.state.position
174
+ });
175
+ });
176
+
177
+ audio.addEventListener('ended', () => {
178
+ this.state.isPlaying = false;
179
+ this.state.position = this.audio.duration;
180
+ this._stopPositionUpdates();
181
+
182
+ // Mark as completed when audio ends (100% listened)
183
+ this._checkAndMarkCompleted();
184
+
185
+ this._emitStateChange('ended');
186
+ eventBus.emit('audio:ended', { contextId: this.state.contextId });
187
+ });
188
+
189
+ audio.addEventListener('timeupdate', () => {
190
+ this.state.position = audio.currentTime;
191
+
192
+ // Track max position for completion calculation
193
+ if (audio.currentTime > this.maxPositionReached) {
194
+ this.maxPositionReached = audio.currentTime;
195
+ }
196
+
197
+ // Check for completion threshold during playback
198
+ this._checkAndMarkCompleted();
199
+ });
200
+
201
+ audio.addEventListener('error', (_e) => {
202
+ const error = audio.error;
203
+
204
+ // Ignore MEDIA_ERR_ABORTED (code 1) during intentional source switches
205
+ // This happens when we change src while audio is loading/playing
206
+ if (this._isSwitchingSource && error?.code === 1) {
207
+ logger.debug('[AudioManager] Ignoring aborted error during source switch');
208
+ return;
209
+ }
210
+
211
+ const errorMessage = error ? `${error.code}: ${error.message}` : 'Unknown error';
212
+
213
+ this.state.isPlaying = false;
214
+ this._stopPositionUpdates();
215
+
216
+ logger.error(`[AudioManager] Audio playback error: ${errorMessage}`, { domain: 'audio', operation: 'playback', src: this.state.currentSrc, contextId: this.state.contextId });
217
+ });
218
+
219
+ audio.addEventListener('volumechange', () => {
220
+ // Only sync volume from this listener, NOT muted state.
221
+ // Muted state is managed explicitly via toggleMute()/setMuted() to prevent
222
+ // browser autoplay policies or source changes from overwriting user preference.
223
+ this.state.volume = audio.volume;
224
+ this._emitStateChange('volumechange');
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Loads an audio file for playback.
230
+ * @param {AudioConfig} config - Audio configuration
231
+ * @param {string} contextId - The context identifier (slideId, modalId, tabId)
232
+ * @param {string} [contextType='slide'] - The type of context
233
+ * @returns {Promise<void>}
234
+ */
235
+ async load(config, contextId, contextType = 'slide') {
236
+ this._requireInitialized();
237
+
238
+ if (!config || !config.src) {
239
+ throw new Error('AudioManager.load: config.src is required');
240
+ }
241
+ if (!contextId) {
242
+ throw new Error('AudioManager.load: contextId is required');
243
+ }
244
+
245
+ // Save position of current track before switching
246
+ if (this.state.currentSrc && this.state.contextId !== contextId) {
247
+ this._savePosition();
248
+ }
249
+
250
+ // Stop current playback
251
+ this.pause();
252
+
253
+ // Resolve the audio path (relative to course/assets/)
254
+ const audioSrc = this._resolvePath(config.src);
255
+
256
+ // Update state with new audio config
257
+ // IMPORTANT: Reset isPlaying to false since new audio isn't playing yet
258
+ this.state.currentSrc = audioSrc;
259
+ this.state.contextId = contextId;
260
+ this.state.contextType = contextType;
261
+ this.state.duration = 0;
262
+ this.state.isPlaying = false; // Reset - new audio isn't playing
263
+ this.state.position = 0; // Reset position for new audio
264
+ this.state.required = config.required || false;
265
+ this.state.completionThreshold = config.completionThreshold ?? DEFAULT_COMPLETION_THRESHOLD;
266
+ this.maxPositionReached = 0;
267
+
268
+ // Check if already completed (from previous session)
269
+ this.state.isCompleted = this._isContextCompleted(contextId);
270
+
271
+ // Check for saved position
272
+ const savedPosition = this._getSavedPosition(contextId);
273
+
274
+ // Emit loadStart event BEFORE setting src (allows UI to show placeholder/loading state)
275
+ eventBus.emit('audio:loadStart', {
276
+ contextId,
277
+ contextType,
278
+ src: audioSrc
279
+ });
280
+
281
+ // Clean up any pending load operation before starting new one
282
+ if (this._pendingLoadCleanup) {
283
+ this._pendingLoadCleanup();
284
+ this._pendingLoadCleanup = null;
285
+ }
286
+
287
+ // Set flag to ignore MEDIA_ERR_ABORTED during source switch
288
+ this._isSwitchingSource = true;
289
+
290
+ return new Promise((resolve, reject) => {
291
+ let resolved = false;
292
+
293
+ const onLoaded = () => {
294
+ if (resolved) return;
295
+ resolved = true;
296
+
297
+ // Restore position if we have one saved, otherwise reset to start
298
+ if (savedPosition > 0 && savedPosition < this.audio.duration) {
299
+ this.audio.currentTime = savedPosition;
300
+ this.state.position = savedPosition;
301
+ // Also restore maxPositionReached for correct completion tracking
302
+ this.maxPositionReached = savedPosition;
303
+ } else {
304
+ this.audio.currentTime = 0;
305
+ this.state.position = 0;
306
+ }
307
+
308
+ // Emit progress event to sync UI with restored position
309
+ eventBus.emit('audio:progress', {
310
+ position: this.state.position,
311
+ duration: this.state.duration,
312
+ percentage: this.getProgressPercentage()
313
+ });
314
+
315
+ // Re-apply mute state to audio element (browser may have reset it on new source)
316
+ // Do this BEFORE clearing _isSwitchingSource so the volumechange event is ignored
317
+ this.audio.muted = this.state.isMuted;
318
+
319
+ // NOW clear the switching flag - after mute state is applied
320
+ this._isSwitchingSource = false;
321
+
322
+ cleanup();
323
+
324
+ // Autoplay if configured
325
+ if (config.autoplay) {
326
+ this.play().catch(err => {
327
+ // Autoplay may be blocked by browser - log but don't fail
328
+ logger.warn('[AudioManager] Autoplay blocked:', err.message);
329
+ });
330
+ }
331
+
332
+ resolve();
333
+ };
334
+
335
+ const onError = (_e) => {
336
+ const error = this.audio.error;
337
+
338
+ // Ignore MEDIA_ERR_ABORTED (code 1) - this fires when we switch sources
339
+ if (error?.code === 1) {
340
+ logger.debug('[AudioManager] Ignoring abort error during load');
341
+ return; // Don't cleanup or reject - wait for the real load
342
+ }
343
+
344
+ if (resolved) return;
345
+ resolved = true;
346
+
347
+ // Clear the switching flag on real error
348
+ this._isSwitchingSource = false;
349
+ cleanup();
350
+ reject(new Error(`Failed to load audio: ${audioSrc}`));
351
+ };
352
+
353
+ const cleanup = () => {
354
+ this.audio.removeEventListener('canplaythrough', onLoaded);
355
+ this.audio.removeEventListener('error', onError);
356
+ this._pendingLoadCleanup = null;
357
+ };
358
+
359
+ // Store cleanup function so it can be called if load is cancelled
360
+ this._pendingLoadCleanup = cleanup;
361
+
362
+ // Add event listeners BEFORE setting src
363
+ this.audio.addEventListener('canplaythrough', onLoaded);
364
+ this.audio.addEventListener('error', onError);
365
+
366
+ // Set the source and trigger load
367
+ this.audio.src = audioSrc;
368
+ this.audio.load();
369
+
370
+ // CRITICAL: Check if audio is already ready (cached audio loads synchronously)
371
+ // readyState 4 = HAVE_ENOUGH_DATA, meaning canplaythrough should have fired
372
+ // but sometimes the event doesn't fire for cached audio
373
+ if (this.audio.readyState >= 4) {
374
+ logger.debug('[AudioManager] Audio already ready (cached), resolving immediately');
375
+ onLoaded();
376
+ }
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Starts or resumes playback.
382
+ * @returns {Promise<void>}
383
+ */
384
+ async play() {
385
+ this._requireInitialized();
386
+
387
+ if (!this.state.currentSrc) {
388
+ logger.warn('[AudioManager] No audio loaded');
389
+ return;
390
+ }
391
+
392
+ try {
393
+ await this.audio.play();
394
+ } catch (error) {
395
+ // Browser may block autoplay - emit event for UI to show play button
396
+ eventBus.emit('audio:playBlocked', {
397
+ contextId: this.state.contextId,
398
+ reason: error.message
399
+ });
400
+ throw error;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Pauses playback.
406
+ */
407
+ pause() {
408
+ this._requireInitialized();
409
+
410
+ if (this.audio && !this.audio.paused) {
411
+ this.audio.pause();
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Toggles play/pause state.
417
+ * @returns {Promise<void>}
418
+ */
419
+ async togglePlayPause() {
420
+ if (this.state.isPlaying) {
421
+ this.pause();
422
+ } else {
423
+ await this.play();
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Restarts the current track from the beginning.
429
+ */
430
+ restart() {
431
+ this._requireInitialized();
432
+
433
+ if (!this.state.currentSrc) {
434
+ return;
435
+ }
436
+
437
+ this.audio.currentTime = 0;
438
+ this.state.position = 0;
439
+ this.maxPositionReached = 0;
440
+ this._clearSavedPosition(this.state.contextId);
441
+
442
+ // Emit progress event to sync UI immediately
443
+ eventBus.emit('audio:progress', {
444
+ position: 0,
445
+ duration: this.state.duration,
446
+ percentage: 0
447
+ });
448
+
449
+ eventBus.emit('audio:restart', { contextId: this.state.contextId });
450
+ }
451
+
452
+ /**
453
+ * Seeks to a specific position.
454
+ * @param {number} position - Position in seconds
455
+ */
456
+ seek(position) {
457
+ this._requireInitialized();
458
+
459
+ if (!this.state.currentSrc || !this.audio.duration) {
460
+ return;
461
+ }
462
+
463
+ const clampedPosition = Math.max(0, Math.min(position, this.audio.duration));
464
+ this.audio.currentTime = clampedPosition;
465
+ this.state.position = clampedPosition;
466
+
467
+ eventBus.emit('audio:seek', {
468
+ contextId: this.state.contextId,
469
+ position: clampedPosition
470
+ });
471
+ }
472
+
473
+ /**
474
+ * Seeks to a percentage of the track duration.
475
+ * @param {number} percentage - Percentage (0-100)
476
+ */
477
+ seekToPercentage(percentage) {
478
+ if (!this.state.duration) return;
479
+
480
+ const position = (percentage / 100) * this.state.duration;
481
+ this.seek(position);
482
+ }
483
+
484
+ /**
485
+ * Toggles mute state.
486
+ */
487
+ toggleMute() {
488
+ this._requireInitialized();
489
+
490
+ // Update our state first (authoritative)
491
+ this.state.isMuted = !this.state.isMuted;
492
+ // Then sync to audio element
493
+ this.audio.muted = this.state.isMuted;
494
+ this._persistMuteState();
495
+ // Emit state change for UI sync
496
+ this._emitStateChange('volumechange');
497
+ }
498
+
499
+ /**
500
+ * Sets the mute state.
501
+ * @param {boolean} muted - Whether to mute
502
+ */
503
+ setMuted(muted) {
504
+ this._requireInitialized();
505
+
506
+ // Update our state first (authoritative)
507
+ this.state.isMuted = muted;
508
+ // Then sync to audio element
509
+ this.audio.muted = muted;
510
+ this._persistMuteState();
511
+ // Emit state change for UI sync
512
+ this._emitStateChange('volumechange');
513
+ }
514
+
515
+ /**
516
+ * Sets the volume.
517
+ * @param {number} volume - Volume level (0-1)
518
+ */
519
+ setVolume(volume) {
520
+ this._requireInitialized();
521
+
522
+ this.audio.volume = Math.max(0, Math.min(1, volume));
523
+ }
524
+
525
+ /**
526
+ * Unloads the current audio and clears state.
527
+ * Called when leaving a slide or closing a modal.
528
+ */
529
+ unload() {
530
+ if (!this.isInitialized) return;
531
+
532
+ // Save position before unloading
533
+ this._savePosition();
534
+
535
+ // Clean up any pending load operation
536
+ if (this._pendingLoadCleanup) {
537
+ this._pendingLoadCleanup();
538
+ this._pendingLoadCleanup = null;
539
+ }
540
+
541
+ // Save contextType before clearing state (needed for event)
542
+ const contextType = this.state.contextType;
543
+
544
+ // Stop playback
545
+ this.pause();
546
+ this._stopPositionUpdates();
547
+
548
+ // Set flag to ignore any MEDIA_ERR_ABORTED during unload
549
+ this._isSwitchingSource = true;
550
+
551
+ // Clear audio source properly
552
+ // NOTE: Do NOT call this.audio.load() after removing src - it triggers an error event
553
+ // that can interfere with subsequent audio loads on the next slide.
554
+ // Just removing the src attribute is sufficient to abort pending requests and reset.
555
+ this.audio.removeAttribute('src');
556
+
557
+ // Clear the switching flag after removing src
558
+ this._isSwitchingSource = false;
559
+
560
+ // Clear state (keep mute preference)
561
+ const wasMuted = this.state.isMuted;
562
+ this.state = {
563
+ currentSrc: null,
564
+ contextId: null,
565
+ contextType: 'slide',
566
+ position: 0,
567
+ isPlaying: false,
568
+ isMuted: wasMuted,
569
+ duration: 0,
570
+ volume: this.state.volume,
571
+ required: false,
572
+ completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
573
+ isCompleted: false
574
+ };
575
+ this.maxPositionReached = 0;
576
+
577
+ this._emitStateChange('unloaded');
578
+ eventBus.emit('audio:unloaded', { contextType });
579
+ }
580
+
581
+ /**
582
+ * Gets the current audio state.
583
+ * @returns {AudioState}
584
+ */
585
+ getState() {
586
+ return { ...this.state };
587
+ }
588
+
589
+ /**
590
+ * Gets the current playback position as a percentage.
591
+ * @returns {number} Percentage (0-100)
592
+ */
593
+ getProgressPercentage() {
594
+ if (!this.state.duration || !isFinite(this.state.duration)) return 0;
595
+ return (this.state.position / this.state.duration) * 100;
596
+ }
597
+
598
+ /**
599
+ * Checks if audio is currently loaded.
600
+ * @returns {boolean}
601
+ */
602
+ hasAudio() {
603
+ return !!this.state.currentSrc;
604
+ }
605
+
606
+ /**
607
+ * Checks if the manager is initialized.
608
+ * @returns {boolean}
609
+ */
610
+ isReady() {
611
+ return this.isInitialized;
612
+ }
613
+
614
+ /**
615
+ * Checks if the current audio has been completed.
616
+ * @returns {boolean}
617
+ */
618
+ isCurrentAudioCompleted() {
619
+ return this.state.isCompleted;
620
+ }
621
+
622
+ /**
623
+ * Checks if audio for a specific context has been completed.
624
+ * @param {string} contextId - The context identifier
625
+ * @returns {boolean}
626
+ */
627
+ isAudioCompleted(contextId) {
628
+ return this._isContextCompleted(contextId);
629
+ }
630
+
631
+ /**
632
+ * Checks if current audio requires completion for gating.
633
+ * @returns {boolean}
634
+ */
635
+ isAudioRequired() {
636
+ return this.state.required;
637
+ }
638
+
639
+ // =================================================================
640
+ // Private Methods
641
+ // =================================================================
642
+
643
+ /**
644
+ * Throws if not initialized.
645
+ * @private
646
+ */
647
+ _requireInitialized() {
648
+ if (!this.isInitialized) {
649
+ throw new Error('AudioManager: Not initialized. Call initialize() first.');
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Resolves audio path relative to course/assets/.
655
+ * Converts narration sources to their generated .mp3 files:
656
+ * - @slides/X.js → audio/X.mp3 (main slide narration)
657
+ * - @slides/X.js#key → audio/X--key.mp3 (modal/tab narration)
658
+ * @private
659
+ * @param {string} src - Source path
660
+ * @returns {string} Resolved path
661
+ */
662
+ _resolvePath(src) {
663
+ let resolvedSrc = src;
664
+
665
+ // Convert @slides/X.js or @slides/X.js#key to audio/X.mp3 or audio/X--key.mp3
666
+ if (src.startsWith('@slides/')) {
667
+ // Check for fragment (#key) for modal/tab narration
668
+ const fragmentMatch = src.match(/^@slides\/([\w-]+)\.js(?:#([\w-]+))?$/);
669
+ if (fragmentMatch) {
670
+ const slideName = fragmentMatch[1];
671
+ const key = fragmentMatch[2];
672
+
673
+ if (key) {
674
+ // Modal/tab specific audio: slidename--key.mp3
675
+ resolvedSrc = `audio/${slideName}--${key}.mp3`;
676
+ } else {
677
+ // Main slide audio: slidename.mp3
678
+ resolvedSrc = `audio/${slideName}.mp3`;
679
+ }
680
+ }
681
+ }
682
+
683
+ // If already a full path or URL, return as-is
684
+ if (resolvedSrc.startsWith('http') || resolvedSrc.startsWith('/') || resolvedSrc.startsWith('./')) {
685
+ return this._appendCacheBuster(resolvedSrc);
686
+ }
687
+ // Otherwise, assume relative to course/assets/
688
+ return this._appendCacheBuster(`./course/assets/${resolvedSrc}`);
689
+ }
690
+
691
+ /**
692
+ * Appends cache-busting query parameter to URL.
693
+ * Uses build timestamp injected by Vite to ensure CDNs serve fresh assets.
694
+ * @private
695
+ * @param {string} url - The URL to append cache buster to
696
+ * @returns {string} URL with cache buster
697
+ */
698
+ _appendCacheBuster(url) {
699
+ // __BUILD_TIMESTAMP__ is injected by Vite at build time
700
+ const buildTimestamp = typeof __BUILD_TIMESTAMP__ !== 'undefined' ? __BUILD_TIMESTAMP__ : Date.now().toString();
701
+
702
+ // Skip cache busting for data URIs
703
+ if (url.startsWith('data:')) {
704
+ return url;
705
+ }
706
+
707
+ // Append as query parameter
708
+ const separator = url.includes('?') ? '&' : '?';
709
+ return `${url}${separator}v=${buildTimestamp}`;
710
+ }
711
+
712
+ /**
713
+ * Emits a state change event with current state.
714
+ * @private
715
+ * @param {string} reason - Reason for the state change
716
+ */
717
+ _emitStateChange(reason) {
718
+ eventBus.emit('audio:stateChange', {
719
+ state: this.getState(),
720
+ reason
721
+ });
722
+ }
723
+
724
+ /**
725
+ * Starts periodic position updates for UI.
726
+ * @private
727
+ */
728
+ _startPositionUpdates() {
729
+ this._stopPositionUpdates();
730
+ this.updateInterval = setInterval(() => {
731
+ // Use audio element's duration directly if state.duration isn't valid
732
+ // This handles cases where duration becomes known after initial load
733
+ let duration = this.state.duration;
734
+ if (!isFinite(duration) && isFinite(this.audio.duration)) {
735
+ duration = this.audio.duration;
736
+ this.state.duration = duration; // Update state with valid duration
737
+ }
738
+
739
+ eventBus.emit('audio:progress', {
740
+ position: this.state.position,
741
+ duration: duration,
742
+ percentage: this.getProgressPercentage()
743
+ });
744
+ }, 250); // Update 4 times per second
745
+ }
746
+
747
+ /**
748
+ * Stops periodic position updates.
749
+ * @private
750
+ */
751
+ _stopPositionUpdates() {
752
+ if (this.updateInterval) {
753
+ clearInterval(this.updateInterval);
754
+ this.updateInterval = null;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Saves the current position for the current context.
760
+ * @private
761
+ */
762
+ _savePosition() {
763
+ if (!this.state.contextId || !this.state.position) return;
764
+
765
+ this.positionCache.set(this.state.contextId, this.state.position);
766
+ this._persistPositions();
767
+ }
768
+
769
+ /**
770
+ * Gets the saved position for a context.
771
+ * @private
772
+ * @param {string} contextId - Context identifier
773
+ * @returns {number} Saved position in seconds (0 if none)
774
+ */
775
+ _getSavedPosition(contextId) {
776
+ return this.positionCache.get(contextId) || 0;
777
+ }
778
+
779
+ /**
780
+ * Clears the saved position for a context.
781
+ * @private
782
+ * @param {string} contextId - Context identifier
783
+ */
784
+ _clearSavedPosition(contextId) {
785
+ this.positionCache.delete(contextId);
786
+ this._persistPositions();
787
+ }
788
+
789
+ /**
790
+ * Persists position cache to stateManager.
791
+ * @private
792
+ */
793
+ _persistPositions() {
794
+ try {
795
+ const positions = Object.fromEntries(this.positionCache);
796
+ const audioState = stateManager.getDomainState('audio') || {};
797
+ stateManager.setDomainState('audio', {
798
+ ...audioState,
799
+ positions
800
+ });
801
+ } catch (error) {
802
+ logger.warn('[AudioManager] Failed to persist positions:', error.message);
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Persists mute state to stateManager.
808
+ * @private
809
+ */
810
+ _persistMuteState() {
811
+ try {
812
+ const audioState = stateManager.getDomainState('audio') || {};
813
+ stateManager.setDomainState('audio', {
814
+ ...audioState,
815
+ muted: this.state.isMuted
816
+ });
817
+ } catch (error) {
818
+ logger.warn('[AudioManager] Failed to persist mute state:', error.message);
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Hydrates state from stateManager on initialization.
824
+ * @private
825
+ */
826
+ _hydrateFromState() {
827
+ try {
828
+ const audioState = stateManager.getDomainState('audio');
829
+ if (!audioState) return;
830
+
831
+ // Restore positions
832
+ if (audioState.positions) {
833
+ this.positionCache = new Map(Object.entries(audioState.positions));
834
+ }
835
+
836
+ // Restore completion states
837
+ if (audioState.completions) {
838
+ this.completionCache = new Map(Object.entries(audioState.completions));
839
+ }
840
+
841
+ // Restore mute state
842
+ if (typeof audioState.muted === 'boolean') {
843
+ this.state.isMuted = audioState.muted;
844
+ this.audio.muted = audioState.muted;
845
+ }
846
+
847
+ logger.debug('[AudioManager] State hydrated from storage');
848
+ } catch (error) {
849
+ logger.warn('[AudioManager] Failed to hydrate state:', error.message);
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Checks if audio has reached completion threshold and marks it complete.
855
+ * @private
856
+ */
857
+ _checkAndMarkCompleted() {
858
+ if (!this.state.contextId || !this.state.duration || this.state.isCompleted) {
859
+ return;
860
+ }
861
+
862
+ // Calculate completion based on max position reached (handles seeks/replays)
863
+ const completionPercentage = this.maxPositionReached / this.state.duration;
864
+
865
+ if (completionPercentage >= this.state.completionThreshold) {
866
+ this.state.isCompleted = true;
867
+ this._markContextCompleted(this.state.contextId);
868
+
869
+ logger.debug(`[AudioManager] Audio completed for context: ${this.state.contextId}`);
870
+
871
+ eventBus.emit('audio:completed', {
872
+ contextId: this.state.contextId,
873
+ contextType: this.state.contextType,
874
+ required: this.state.required
875
+ });
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Marks a context's audio as completed.
881
+ * @private
882
+ * @param {string} contextId - Context identifier
883
+ */
884
+ _markContextCompleted(contextId) {
885
+ this.completionCache.set(contextId, true);
886
+ this._persistCompletions();
887
+ }
888
+
889
+ /**
890
+ * Checks if a context's audio has been completed.
891
+ * @private
892
+ * @param {string} contextId - Context identifier
893
+ * @returns {boolean}
894
+ */
895
+ _isContextCompleted(contextId) {
896
+ return this.completionCache.get(contextId) || false;
897
+ }
898
+
899
+ /**
900
+ * Persists completion cache to stateManager.
901
+ * @private
902
+ */
903
+ _persistCompletions() {
904
+ try {
905
+ const completions = Object.fromEntries(this.completionCache);
906
+ const audioState = stateManager.getDomainState('audio') || {};
907
+ stateManager.setDomainState('audio', {
908
+ ...audioState,
909
+ completions
910
+ });
911
+ } catch (error) {
912
+ logger.warn('[AudioManager] Failed to persist completions:', error.message);
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Resets completion state for a specific context.
918
+ * Useful for course retakes.
919
+ * @param {string} contextId - Context identifier
920
+ */
921
+ resetCompletion(contextId) {
922
+ this.completionCache.delete(contextId);
923
+ this._persistCompletions();
924
+
925
+ if (this.state.contextId === contextId) {
926
+ this.state.isCompleted = false;
927
+ }
928
+ }
929
+
930
+ /**
931
+ * Resets all completion states.
932
+ * Useful for full course retakes.
933
+ */
934
+ resetAllCompletions() {
935
+ this.completionCache.clear();
936
+ this._persistCompletions();
937
+ this.state.isCompleted = false;
938
+ }
939
+ }
940
+
941
+ // Create singleton instance
942
+ const audioManager = new AudioManager();
943
+
944
+ export default audioManager;