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