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.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/cli.js +331 -0
- package/framework/assets/logo-coursecode-black.svg +14 -0
- package/framework/assets/logo-coursecode-white.svg +14 -0
- package/framework/assets/logo-coursecode.svg +14 -0
- package/framework/css/01-base.css +160 -0
- package/framework/css/02-layout.css +499 -0
- package/framework/css/accessibility.css +834 -0
- package/framework/css/components/accordions.css +710 -0
- package/framework/css/components/assessments.css +520 -0
- package/framework/css/components/audio-player.css +570 -0
- package/framework/css/components/badges.css +80 -0
- package/framework/css/components/breadcrumbs.css +87 -0
- package/framework/css/components/buttons.css +707 -0
- package/framework/css/components/callouts.css +1280 -0
- package/framework/css/components/cards.css +475 -0
- package/framework/css/components/carousel.css +193 -0
- package/framework/css/components/checkbox-group.css +123 -0
- package/framework/css/components/checklist.css +203 -0
- package/framework/css/components/collapse.css +96 -0
- package/framework/css/components/comparison.css +33 -0
- package/framework/css/components/content-image.css +36 -0
- package/framework/css/components/document-gallery.css +425 -0
- package/framework/css/components/dropdown.css +115 -0
- package/framework/css/components/embed-frame.css +142 -0
- package/framework/css/components/engagement.css +412 -0
- package/framework/css/components/features.css +35 -0
- package/framework/css/components/flip-cards.css +253 -0
- package/framework/css/components/footer.css +353 -0
- package/framework/css/components/forms.css +294 -0
- package/framework/css/components/hero.css +216 -0
- package/framework/css/components/images.css +528 -0
- package/framework/css/components/interactive-timeline.css +274 -0
- package/framework/css/components/intro-cards.css +30 -0
- package/framework/css/components/lightbox.css +666 -0
- package/framework/css/components/loading.css +65 -0
- package/framework/css/components/modals.css +235 -0
- package/framework/css/components/notifications.css +107 -0
- package/framework/css/components/quote.css +150 -0
- package/framework/css/components/sidebar.css +684 -0
- package/framework/css/components/slide-header.css +52 -0
- package/framework/css/components/spinner.css +62 -0
- package/framework/css/components/stats.css +44 -0
- package/framework/css/components/steps.css +232 -0
- package/framework/css/components/tables.css +90 -0
- package/framework/css/components/tabs.css +347 -0
- package/framework/css/components/timeline.css +154 -0
- package/framework/css/components/toggle.css +95 -0
- package/framework/css/components/tooltip.css +226 -0
- package/framework/css/components/video-player.css +438 -0
- package/framework/css/design-tokens.css +707 -0
- package/framework/css/framework.css +86 -0
- package/framework/css/interactions/accessibility.css +75 -0
- package/framework/css/interactions/base.css +92 -0
- package/framework/css/interactions/drag-drop.css +295 -0
- package/framework/css/interactions/fill-in-the-blank.css +236 -0
- package/framework/css/interactions/hotspots.css +69 -0
- package/framework/css/interactions/index.css +45 -0
- package/framework/css/interactions/interactive-image.css +359 -0
- package/framework/css/interactions/likert.css +126 -0
- package/framework/css/interactions/matching.css +354 -0
- package/framework/css/interactions/numeric-input.css +78 -0
- package/framework/css/interactions/sequencing.css +378 -0
- package/framework/css/interactions/true-false.css +177 -0
- package/framework/css/layouts/article.css +258 -0
- package/framework/css/layouts/base.css +30 -0
- package/framework/css/layouts/canvas.css +38 -0
- package/framework/css/layouts/focused.css +236 -0
- package/framework/css/layouts/index.css +29 -0
- package/framework/css/layouts/presentation.css +191 -0
- package/framework/css/layouts/traditional.css +52 -0
- package/framework/css/responsive.css +439 -0
- package/framework/css/utilities/accessibility-utils.css +59 -0
- package/framework/css/utilities/animations.css +419 -0
- package/framework/css/utilities/borders.css +72 -0
- package/framework/css/utilities/colors.css +76 -0
- package/framework/css/utilities/container.css +46 -0
- package/framework/css/utilities/decorative.css +442 -0
- package/framework/css/utilities/display.css +257 -0
- package/framework/css/utilities/flexbox.css +80 -0
- package/framework/css/utilities/grid.css +69 -0
- package/framework/css/utilities/icons.css +534 -0
- package/framework/css/utilities/lists.css +190 -0
- package/framework/css/utilities/spacing.css +167 -0
- package/framework/css/utilities/tables.css +81 -0
- package/framework/css/utilities/typography.css +159 -0
- package/framework/css/utilities/visibility.css +117 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
- package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
- package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
- package/framework/docs/DATA_MODEL.md +409 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
- package/framework/docs/USER_GUIDE.md +583 -0
- package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
- package/framework/docs/examples/cloudflare-data-worker.js +102 -0
- package/framework/docs/examples/cloudflare-error-worker.js +228 -0
- package/framework/index.html +175 -0
- package/framework/js/app/AppActions.js +410 -0
- package/framework/js/app/AppState.js +225 -0
- package/framework/js/app/AppUI.js +616 -0
- package/framework/js/assessment/AssessmentActions.js +615 -0
- package/framework/js/assessment/AssessmentFactory.js +471 -0
- package/framework/js/assessment/AssessmentState.js +322 -0
- package/framework/js/assessment/AssessmentUI.js +451 -0
- package/framework/js/automation/api-engagement.js +196 -0
- package/framework/js/automation/api-interactions.js +167 -0
- package/framework/js/automation/api.js +242 -0
- package/framework/js/automation/index.js +41 -0
- package/framework/js/components/interactions/drag-drop.js +884 -0
- package/framework/js/components/interactions/fill-in.js +535 -0
- package/framework/js/components/interactions/hotspot.js +702 -0
- package/framework/js/components/interactions/interaction-base.js +511 -0
- package/framework/js/components/interactions/likert.js +301 -0
- package/framework/js/components/interactions/matching.js +699 -0
- package/framework/js/components/interactions/multiple-choice.js +377 -0
- package/framework/js/components/interactions/numeric.js +271 -0
- package/framework/js/components/interactions/sequencing.js +423 -0
- package/framework/js/components/interactions/true-false.js +241 -0
- package/framework/js/components/ui-components/accordion.js +442 -0
- package/framework/js/components/ui-components/alert.js +88 -0
- package/framework/js/components/ui-components/audio-player.js +1193 -0
- package/framework/js/components/ui-components/callout.js +121 -0
- package/framework/js/components/ui-components/carousel.js +145 -0
- package/framework/js/components/ui-components/checkbox-group.js +87 -0
- package/framework/js/components/ui-components/checklist.js +40 -0
- package/framework/js/components/ui-components/collapse.js +114 -0
- package/framework/js/components/ui-components/comparison.js +30 -0
- package/framework/js/components/ui-components/conditional-display.js +150 -0
- package/framework/js/components/ui-components/content-image.js +41 -0
- package/framework/js/components/ui-components/dropdown.js +262 -0
- package/framework/js/components/ui-components/embed-frame.js +274 -0
- package/framework/js/components/ui-components/features.js +33 -0
- package/framework/js/components/ui-components/flip-card.js +230 -0
- package/framework/js/components/ui-components/form-validator.js +76 -0
- package/framework/js/components/ui-components/hero.js +49 -0
- package/framework/js/components/ui-components/index.js +12 -0
- package/framework/js/components/ui-components/interactive-image.js +235 -0
- package/framework/js/components/ui-components/interactive-timeline.js +285 -0
- package/framework/js/components/ui-components/intro-cards.js +35 -0
- package/framework/js/components/ui-components/lightbox.js +652 -0
- package/framework/js/components/ui-components/modal.js +386 -0
- package/framework/js/components/ui-components/notifications.js +145 -0
- package/framework/js/components/ui-components/progress.js +88 -0
- package/framework/js/components/ui-components/quote.js +41 -0
- package/framework/js/components/ui-components/stats.js +33 -0
- package/framework/js/components/ui-components/steps.js +41 -0
- package/framework/js/components/ui-components/tabs.js +255 -0
- package/framework/js/components/ui-components/timeline.js +42 -0
- package/framework/js/components/ui-components/toggle-group.js +73 -0
- package/framework/js/components/ui-components/tooltip.js +458 -0
- package/framework/js/components/ui-components/value-display.js +133 -0
- package/framework/js/components/ui-components/video-player.js +686 -0
- package/framework/js/core/component-catalog.js +121 -0
- package/framework/js/core/event-bus.js +178 -0
- package/framework/js/core/interaction-catalog.js +149 -0
- package/framework/js/dev/runtime-linter.js +1725 -0
- package/framework/js/drivers/cmi5-driver.js +768 -0
- package/framework/js/drivers/driver-factory.js +77 -0
- package/framework/js/drivers/driver-interface.js +110 -0
- package/framework/js/drivers/http-driver-base.js +241 -0
- package/framework/js/drivers/lti-driver.js +508 -0
- package/framework/js/drivers/proxy-driver.js +444 -0
- package/framework/js/drivers/scorm-12-driver.js +560 -0
- package/framework/js/drivers/scorm-2004-driver.js +775 -0
- package/framework/js/drivers/scorm-driver-base.js +112 -0
- package/framework/js/engagement/engagement-manager.js +404 -0
- package/framework/js/engagement/engagement-progress.js +191 -0
- package/framework/js/engagement/engagement-trackers.js +215 -0
- package/framework/js/engagement/requirement-strategies.js +268 -0
- package/framework/js/main.js +727 -0
- package/framework/js/managers/accessibility-manager.js +499 -0
- package/framework/js/managers/assessment-manager.js +230 -0
- package/framework/js/managers/audio-manager.js +944 -0
- package/framework/js/managers/comment-manager.js +88 -0
- package/framework/js/managers/flag-manager.js +86 -0
- package/framework/js/managers/interaction-manager.js +254 -0
- package/framework/js/managers/interaction-registry.js +96 -0
- package/framework/js/managers/objective-manager.js +423 -0
- package/framework/js/managers/score-manager.js +441 -0
- package/framework/js/managers/video-manager.js +536 -0
- package/framework/js/navigation/Breadcrumbs.js +234 -0
- package/framework/js/navigation/NavigationActions.js +1132 -0
- package/framework/js/navigation/NavigationState.js +276 -0
- package/framework/js/navigation/NavigationUI.js +574 -0
- package/framework/js/navigation/document-gallery.js +357 -0
- package/framework/js/navigation/navigation-helpers.js +175 -0
- package/framework/js/navigation/navigation-validators.js +174 -0
- package/framework/js/state/index.js +8 -0
- package/framework/js/state/lms-connection.js +482 -0
- package/framework/js/state/lms-error-utils.js +58 -0
- package/framework/js/state/state-commits.js +200 -0
- package/framework/js/state/state-domains.js +86 -0
- package/framework/js/state/state-manager.js +502 -0
- package/framework/js/state/state-validation.js +311 -0
- package/framework/js/state/transaction-log.js +41 -0
- package/framework/js/state/xapi-statement-service.js +325 -0
- package/framework/js/utilities/access-control.js +99 -0
- package/framework/js/utilities/breakpoint-manager.js +315 -0
- package/framework/js/utilities/canvas-slide.js +35 -0
- package/framework/js/utilities/conditional-display.js +388 -0
- package/framework/js/utilities/course-channel.js +214 -0
- package/framework/js/utilities/course-helpers.js +420 -0
- package/framework/js/utilities/data-reporter.js +273 -0
- package/framework/js/utilities/error-reporter.js +313 -0
- package/framework/js/utilities/hotspot-helper.js +341 -0
- package/framework/js/utilities/icons.js +348 -0
- package/framework/js/utilities/logger.js +92 -0
- package/framework/js/utilities/markdown-renderer.js +45 -0
- package/framework/js/utilities/scroll-tracker.js +68 -0
- package/framework/js/utilities/ui-initializer.js +146 -0
- package/framework/js/utilities/utilities.js +293 -0
- package/framework/js/utilities/view-manager.js +227 -0
- package/framework/js/validation/html-validators.js +422 -0
- package/framework/js/validation/scorm-validators.js +438 -0
- package/framework/js/vendor/pipwerks.js +931 -0
- package/framework/scripts/generate-narration.js +629 -0
- package/framework/scripts/tts-providers/azure-provider.js +178 -0
- package/framework/scripts/tts-providers/base-provider.js +81 -0
- package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
- package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
- package/framework/scripts/tts-providers/google-provider.js +272 -0
- package/framework/scripts/tts-providers/index.js +158 -0
- package/framework/scripts/tts-providers/openai-provider.js +143 -0
- package/framework/version.json +63 -0
- package/lib/authoring-api.js +919 -0
- package/lib/build-linter.js +450 -0
- package/lib/build-packaging.js +186 -0
- package/lib/build.js +88 -0
- package/lib/cloud.js +691 -0
- package/lib/convert.js +341 -0
- package/lib/course-parser.js +936 -0
- package/lib/course-writer.js +258 -0
- package/lib/create.js +248 -0
- package/lib/css-index.js +237 -0
- package/lib/dev.js +51 -0
- package/lib/export-content.js +1246 -0
- package/lib/headless-browser.js +413 -0
- package/lib/import.js +377 -0
- package/lib/index.js +80 -0
- package/lib/info.js +79 -0
- package/lib/interaction-formatters.js +568 -0
- package/lib/manifest/cmi5-manifest.js +63 -0
- package/lib/manifest/lti-tool-config.js +53 -0
- package/lib/manifest/manifest-factory.js +99 -0
- package/lib/manifest/scorm-12-manifest.js +61 -0
- package/lib/manifest/scorm-2004-manifest.js +94 -0
- package/lib/manifest/scorm-proxy-manifest.js +104 -0
- package/lib/manifest-parser.js +96 -0
- package/lib/mcp-prompts.js +753 -0
- package/lib/mcp-server.js +316 -0
- package/lib/narration.js +53 -0
- package/lib/pdf-structure.js +142 -0
- package/lib/preview-export.js +231 -0
- package/lib/preview-routes-api.js +662 -0
- package/lib/preview-routes-editing.js +159 -0
- package/lib/preview-routes-lms.js +230 -0
- package/lib/preview-server.js +564 -0
- package/lib/project-utils.js +269 -0
- package/lib/proxy-templates/proxy.html +68 -0
- package/lib/proxy-templates/scorm-bridge.js +112 -0
- package/lib/scaffold.js +193 -0
- package/lib/schema-extractor.js +361 -0
- package/lib/slide-source-editor.js +586 -0
- package/lib/stub-player/app-viewer.js +195 -0
- package/lib/stub-player/app.js +370 -0
- package/lib/stub-player/catalog-panel.js +312 -0
- package/lib/stub-player/config-panel.js +1303 -0
- package/lib/stub-player/content-generator.js +586 -0
- package/lib/stub-player/content-viewer.js +173 -0
- package/lib/stub-player/debug-panel.js +420 -0
- package/lib/stub-player/edit-mode.js +922 -0
- package/lib/stub-player/edit-utils.js +400 -0
- package/lib/stub-player/header-bar.js +354 -0
- package/lib/stub-player/interaction-editor.js +210 -0
- package/lib/stub-player/interactions-panel.js +565 -0
- package/lib/stub-player/lms-api.js +1094 -0
- package/lib/stub-player/login-screen.js +74 -0
- package/lib/stub-player/outline-mode.js +689 -0
- package/lib/stub-player/styles/_assessments-panel.css +245 -0
- package/lib/stub-player/styles/_base.css +89 -0
- package/lib/stub-player/styles/_catalog-icons.css +96 -0
- package/lib/stub-player/styles/_catalog-panel.css +291 -0
- package/lib/stub-player/styles/_config-panel.css +636 -0
- package/lib/stub-player/styles/_content-viewer.css +834 -0
- package/lib/stub-player/styles/_debug-panel.css +576 -0
- package/lib/stub-player/styles/_edit-mode.css +128 -0
- package/lib/stub-player/styles/_header-bar.css +343 -0
- package/lib/stub-player/styles/_interaction-editor.css +140 -0
- package/lib/stub-player/styles/_interactions-panel.css +1038 -0
- package/lib/stub-player/styles/_login-screen.css +102 -0
- package/lib/stub-player/styles/_outline-mode.css +752 -0
- package/lib/stub-player/styles.css +15 -0
- package/lib/stub-player.js +160 -0
- package/lib/test-data-reporting.js +176 -0
- package/lib/test-error-reporting.js +146 -0
- package/lib/token.js +86 -0
- package/lib/upgrade.js +257 -0
- package/lib/validation-rules.js +517 -0
- package/lib/vite-plugin-content-discovery.js +296 -0
- package/package.json +108 -0
- package/schemas/XMLSchema.dtd +402 -0
- package/schemas/adlcp_v1p3.xsd +111 -0
- package/schemas/adlnav_v1p3.xsd +61 -0
- package/schemas/adlseq_v1p3.xsd +93 -0
- package/schemas/common/anyElement.xsd +27 -0
- package/schemas/common/dataTypes.xsd +138 -0
- package/schemas/common/elementNames.xsd +767 -0
- package/schemas/common/elementTypes.xsd +786 -0
- package/schemas/common/rootElement.xsd +31 -0
- package/schemas/common/vocabTypes.xsd +345 -0
- package/schemas/common/vocabValues.xsd +257 -0
- package/schemas/datatypes.dtd +203 -0
- package/schemas/ims_xml.xsd +35 -0
- package/schemas/imscp_v1p1.xsd +368 -0
- package/schemas/imsss_v1p0.xsd +67 -0
- package/schemas/imsss_v1p0auxresource.xsd +19 -0
- package/schemas/imsss_v1p0control.xsd +20 -0
- package/schemas/imsss_v1p0delivery.xsd +17 -0
- package/schemas/imsss_v1p0limit.xsd +47 -0
- package/schemas/imsss_v1p0objective.xsd +67 -0
- package/schemas/imsss_v1p0random.xsd +16 -0
- package/schemas/imsss_v1p0rollup.xsd +46 -0
- package/schemas/imsss_v1p0seqrule.xsd +108 -0
- package/schemas/imsss_v1p0util.xsd +94 -0
- package/schemas/license.txt +17 -0
- package/schemas/lom.xsd +102 -0
- package/schemas/lomCustom.xsd +62 -0
- package/schemas/lomLoose.xsd +62 -0
- package/schemas/lomStrict.xsd +62 -0
- package/schemas/xml.xsd +81 -0
- package/template/.env.example +92 -0
- package/template/course/assets/audio/example-intro.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
- package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
- package/template/course/assets/docs/example_md_1.md +39 -0
- package/template/course/assets/docs/example_md_2.md +41 -0
- package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
- package/template/course/assets/docs/example_pdf_2.pdf +0 -0
- package/template/course/assets/images/course-architecture.svg +36 -0
- package/template/course/assets/images/logo.svg +14 -0
- package/template/course/assets/widgets/counter-demo.html +190 -0
- package/template/course/assets/widgets/gravity-painter.html +384 -0
- package/template/course/course-config.js +539 -0
- package/template/course/icons.js +19 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
- package/template/course/slides/example-course-structure.js +138 -0
- package/template/course/slides/example-final-exam.js +144 -0
- package/template/course/slides/example-finishing.js +127 -0
- package/template/course/slides/example-interactions-showcase.js +615 -0
- package/template/course/slides/example-preview-tour.js +129 -0
- package/template/course/slides/example-remedial.js +143 -0
- package/template/course/slides/example-summary.js +103 -0
- package/template/course/slides/example-ui-showcase.js +1805 -0
- package/template/course/slides/example-welcome.js +123 -0
- package/template/course/slides/example-workflow.js +140 -0
- package/template/course/theme.css +165 -0
- package/template/eslint.config.js +47 -0
- package/template/package.json +28 -0
- 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;
|