@pie-element/mc-populated-blank 0.2.10 → 0.2.11
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/README.md +0 -2
- package/dist/author/index.js +186 -152
- package/dist/author/index.js.map +1 -1
- package/dist/controller/defaults.d.ts +41 -73
- package/dist/controller/defaults.d.ts.map +1 -1
- package/dist/controller/index.d.ts +154 -10
- package/dist/controller/index.d.ts.map +1 -1
- package/dist/controller/index.js +29 -30
- package/dist/controller/index.js.map +1 -1
- package/dist/controller/index.test.d.ts +63 -0
- package/dist/controller/index.test.d.ts.map +1 -0
- package/dist/delivery/AudioPlayer.test.d.ts +2 -0
- package/dist/delivery/AudioPlayer.test.d.ts.map +1 -0
- package/dist/delivery/ChoiceRow.test.d.ts +2 -0
- package/dist/delivery/ChoiceRow.test.d.ts.map +1 -0
- package/dist/delivery/ClozeMarker.test.d.ts +2 -0
- package/dist/delivery/ClozeMarker.test.d.ts.map +1 -0
- package/dist/delivery/McPopulatedBlank.transcript.test.d.ts +2 -0
- package/dist/delivery/McPopulatedBlank.transcript.test.d.ts.map +1 -0
- package/dist/delivery/computeAudioMode.d.ts +7 -0
- package/dist/delivery/computeAudioMode.d.ts.map +1 -0
- package/dist/delivery/computeAudioMode.test.d.ts +2 -0
- package/dist/delivery/computeAudioMode.test.d.ts.map +1 -0
- package/dist/delivery/computeChoiceCorrectness.d.ts +18 -0
- package/dist/delivery/computeChoiceCorrectness.d.ts.map +1 -0
- package/dist/delivery/computeChoiceCorrectness.test.d.ts +19 -0
- package/dist/delivery/computeChoiceCorrectness.test.d.ts.map +1 -0
- package/dist/delivery/computeDisplayState.d.ts +47 -0
- package/dist/delivery/computeDisplayState.d.ts.map +1 -0
- package/dist/delivery/computeDisplayState.test.d.ts +38 -0
- package/dist/delivery/computeDisplayState.test.d.ts.map +1 -0
- package/dist/delivery/computeLayoutProfile.d.ts +18 -0
- package/dist/delivery/computeLayoutProfile.d.ts.map +1 -0
- package/dist/delivery/computeLayoutProfile.test.d.ts +2 -0
- package/dist/delivery/computeLayoutProfile.test.d.ts.map +1 -0
- package/dist/delivery/computeLayoutStyle.d.ts +27 -0
- package/dist/delivery/computeLayoutStyle.d.ts.map +1 -0
- package/dist/delivery/computeLayoutStyle.test.d.ts +2 -0
- package/dist/delivery/computeLayoutStyle.test.d.ts.map +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1591 -927
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/variant-css-map.d.ts.map +1 -1
- package/dist/index.iife.d.ts +151 -10
- package/dist/index.iife.d.ts.map +1 -1
- package/dist/index.iife.js +233 -114
- package/dist/index.js +1591 -927
- package/dist/index.js.map +1 -1
- package/dist/print/index.js +194 -166
- package/dist/print/index.js.map +1 -1
- package/dist/shared/layoutLimits.d.ts +40 -0
- package/dist/shared/layoutLimits.d.ts.map +1 -0
- package/dist/shared/types.d.ts +82 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/package.json +4 -2
- package/src/controller/defaults.ts +9 -39
- package/src/controller/index.test.ts +428 -0
- package/src/controller/index.ts +63 -50
- package/src/delivery/AudioPlayer.svelte +246 -0
- package/src/delivery/AudioPlayer.test.ts +149 -0
- package/src/delivery/ChoiceRow.svelte +222 -0
- package/src/delivery/ChoiceRow.test.ts +179 -0
- package/src/delivery/ClozeMarker.svelte +83 -0
- package/src/delivery/ClozeMarker.test.ts +128 -0
- package/src/delivery/McPopulatedBlank.svelte +225 -732
- package/src/delivery/McPopulatedBlank.transcript.test.ts +154 -0
- package/src/delivery/computeAudioMode.test.ts +40 -0
- package/src/delivery/computeAudioMode.ts +11 -0
- package/src/delivery/computeChoiceCorrectness.test.ts +120 -0
- package/src/delivery/computeChoiceCorrectness.ts +42 -0
- package/src/delivery/computeDisplayState.test.ts +274 -0
- package/src/delivery/computeDisplayState.ts +101 -0
- package/src/delivery/computeLayoutProfile.test.ts +163 -0
- package/src/delivery/computeLayoutProfile.ts +72 -0
- package/src/delivery/computeLayoutStyle.test.ts +168 -0
- package/src/delivery/computeLayoutStyle.ts +128 -0
- package/src/delivery/cqt-css/sel-r1-base.css +59 -0
- package/src/delivery/cqt-css/sel-r1-g-stem.css +5 -20
- package/src/delivery/cqt-css/sel-r1-gg-plus.css +3 -20
- package/src/delivery/cqt-css/sel-r1-ggplus.css +4 -21
- package/src/delivery/cqt-css/sel-r1-gplusggg.css +16 -11
- package/src/delivery/cqt-css/sel-r1-plusggg.css +8 -11
- package/src/delivery/cqt-css/sel-r1-s3.css +3 -9
- package/src/delivery/cqt-css/sel-vic.css +61 -0
- package/src/delivery/cqt-css/sr-vic.css +49 -0
- package/src/delivery/index.ts +14 -23
- package/src/delivery/variant-css-map.ts +26 -12
- package/src/shared/layoutLimits.ts +76 -0
- package/src/shared/types.ts +84 -0
package/src/controller/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import defaults, { BLANK_TOKEN, DEFAULT_LAYOUT_LIMITS } from './defaults';
|
|
2
|
+
import type {
|
|
3
|
+
McpbChoice,
|
|
4
|
+
McpbQuestion,
|
|
5
|
+
McpbSession,
|
|
6
|
+
McpbEnv,
|
|
7
|
+
McpbCorrectness,
|
|
8
|
+
} from '../shared/types';
|
|
2
9
|
|
|
3
10
|
const isEmptyObject = (value: unknown): boolean =>
|
|
4
11
|
!!value &&
|
|
@@ -12,8 +19,8 @@ export function countBlankTokens(template: string): number {
|
|
|
12
19
|
return (template.match(re) || []).length;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
export const getCorrectness = (question:
|
|
16
|
-
if (!session
|
|
22
|
+
export const getCorrectness = (question: McpbQuestion, session: McpbSession): McpbCorrectness => {
|
|
23
|
+
if (!session?.choiceId) {
|
|
17
24
|
return 'unanswered';
|
|
18
25
|
}
|
|
19
26
|
const correct = question?.correctChoiceId || '';
|
|
@@ -23,28 +30,14 @@ export const getCorrectness = (question: any, session: any) => {
|
|
|
23
30
|
return 'incorrect';
|
|
24
31
|
};
|
|
25
32
|
|
|
26
|
-
export const getPartialScore = (_question:
|
|
33
|
+
export const getPartialScore = (_question: McpbQuestion, session: McpbSession) => {
|
|
27
34
|
if (!session || isEmptyObject(session) || !session.choiceId) {
|
|
28
35
|
return 0;
|
|
29
36
|
}
|
|
30
37
|
return 1;
|
|
31
38
|
};
|
|
32
39
|
|
|
33
|
-
export const
|
|
34
|
-
if (!session || !session.choiceId) {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
const autoplayAudioEnabled = !!question?.autoplayAudioEnabled;
|
|
38
|
-
const completeAudioEnabled = !!question?.completeAudioEnabled;
|
|
39
|
-
const requiresAudioCompletion =
|
|
40
|
-
autoplayAudioEnabled && completeAudioEnabled && !!question?.hasAudio;
|
|
41
|
-
if (requiresAudioCompletion && !audioComplete) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
return true;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export const outcome = (question: any, session: any, env: any) =>
|
|
40
|
+
export const outcome = (question: McpbQuestion, session: McpbSession, env: McpbEnv) =>
|
|
48
41
|
new Promise((resolve) => {
|
|
49
42
|
if (!session || isEmptyObject(session)) {
|
|
50
43
|
resolve({
|
|
@@ -77,7 +70,7 @@ export const outcome = (question: any, session: any, env: any) =>
|
|
|
77
70
|
resolve({ score, empty: false, traceLog });
|
|
78
71
|
});
|
|
79
72
|
|
|
80
|
-
export const createDefaultModel = (model:
|
|
73
|
+
export const createDefaultModel = (model: McpbQuestion = {}) => ({
|
|
81
74
|
...defaults.model,
|
|
82
75
|
...model,
|
|
83
76
|
layoutLimits: {
|
|
@@ -86,13 +79,13 @@ export const createDefaultModel = (model: any = {}) => ({
|
|
|
86
79
|
},
|
|
87
80
|
});
|
|
88
81
|
|
|
89
|
-
export const normalize = (question:
|
|
82
|
+
export const normalize = (question: McpbQuestion = {}) => createDefaultModel(question);
|
|
90
83
|
|
|
91
|
-
export const normalizeSession = (s:
|
|
84
|
+
export const normalizeSession = (s: McpbSession): McpbSession => ({ ...s });
|
|
92
85
|
|
|
93
|
-
const shouldShuffleChoices = (question:
|
|
86
|
+
const shouldShuffleChoices = (question: McpbQuestion) => !!question?.shuffle;
|
|
94
87
|
|
|
95
|
-
const shouldLockChoices = (question:
|
|
88
|
+
const shouldLockChoices = (question: McpbQuestion, env: McpbEnv) => {
|
|
96
89
|
if (question?.lockChoiceOrder) return true;
|
|
97
90
|
if (env?.['@pie-element']?.lockChoiceOrder) return true;
|
|
98
91
|
return env?.role === 'instructor';
|
|
@@ -107,28 +100,43 @@ function shuffleArray<T>(items: T[]): T[] {
|
|
|
107
100
|
return out;
|
|
108
101
|
}
|
|
109
102
|
|
|
110
|
-
const getStoredShuffle = (session:
|
|
103
|
+
const getStoredShuffle = (session: McpbSession): string[] =>
|
|
111
104
|
Array.isArray(session?.data?.shuffledValues)
|
|
112
105
|
? session.data.shuffledValues
|
|
113
106
|
: Array.isArray(session?.shuffledValues)
|
|
114
107
|
? session.shuffledValues
|
|
115
108
|
: [];
|
|
116
109
|
|
|
117
|
-
const applyShuffledValues = (
|
|
110
|
+
const applyShuffledValues = (
|
|
111
|
+
choices: McpbChoice[],
|
|
112
|
+
shuffledValues: string[],
|
|
113
|
+
choiceKey: keyof McpbChoice
|
|
114
|
+
) => {
|
|
118
115
|
const orderedChoices = shuffledValues
|
|
119
116
|
.map((value) => choices.find((choice) => choice?.[choiceKey] === value))
|
|
120
|
-
.filter(
|
|
117
|
+
.filter((c): c is McpbChoice => !!c);
|
|
121
118
|
|
|
122
119
|
if (orderedChoices.length === choices.length) {
|
|
123
120
|
return orderedChoices;
|
|
124
121
|
}
|
|
125
122
|
|
|
126
|
-
const orderedValues = new Set(orderedChoices.map((choice
|
|
123
|
+
const orderedValues = new Set(orderedChoices.map((choice) => choice[choiceKey]));
|
|
127
124
|
const leftovers = choices.filter((choice) => !orderedValues.has(choice?.[choiceKey]));
|
|
128
125
|
return [...orderedChoices, ...leftovers];
|
|
129
126
|
};
|
|
130
127
|
|
|
131
|
-
|
|
128
|
+
type UpdateSessionFn = (
|
|
129
|
+
id: string,
|
|
130
|
+
element: string,
|
|
131
|
+
data: { shuffledValues: string[] }
|
|
132
|
+
) => Promise<void>;
|
|
133
|
+
|
|
134
|
+
const getOrderedChoices = async (
|
|
135
|
+
question: McpbQuestion,
|
|
136
|
+
session: McpbSession,
|
|
137
|
+
env: McpbEnv,
|
|
138
|
+
updateSession?: UpdateSessionFn
|
|
139
|
+
) => {
|
|
132
140
|
const choices = Array.isArray(question?.choices) ? [...question.choices] : [];
|
|
133
141
|
if (!choices.length || !shouldShuffleChoices(question)) {
|
|
134
142
|
return choices;
|
|
@@ -155,13 +163,18 @@ const getOrderedChoices = async (question: any, session: any, env: any, updateSe
|
|
|
155
163
|
return shuffledChoices;
|
|
156
164
|
};
|
|
157
165
|
|
|
158
|
-
export const model = async (
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
export const model = async (
|
|
167
|
+
question: McpbQuestion,
|
|
168
|
+
session: McpbSession | null,
|
|
169
|
+
env: McpbEnv | null,
|
|
170
|
+
updateSession?: UpdateSessionFn
|
|
171
|
+
) => {
|
|
172
|
+
const safeSession: McpbSession = session || {};
|
|
173
|
+
const safeEnv: McpbEnv = env || {};
|
|
161
174
|
const normalizedQuestion = normalize(question);
|
|
162
|
-
const choices = await getOrderedChoices(normalizedQuestion,
|
|
175
|
+
const choices = await getOrderedChoices(normalizedQuestion, safeSession, safeEnv, updateSession);
|
|
163
176
|
|
|
164
|
-
const out:
|
|
177
|
+
const out: Record<string, unknown> = {
|
|
165
178
|
prompt: normalizedQuestion.promptEnabled ? normalizedQuestion.prompt : null,
|
|
166
179
|
interactionMode: normalizedQuestion.interactionMode || 'populate_blank',
|
|
167
180
|
layoutProfile: normalizedQuestion.layoutProfile || '',
|
|
@@ -206,7 +219,7 @@ export const model = async (question: any, session: any, env: any, updateSession
|
|
|
206
219
|
};
|
|
207
220
|
|
|
208
221
|
if (safeEnv.mode === 'evaluate') {
|
|
209
|
-
const correctness = getCorrectness(normalizedQuestion,
|
|
222
|
+
const correctness = getCorrectness(normalizedQuestion, safeSession);
|
|
210
223
|
out.correctness = correctness;
|
|
211
224
|
out.responseCorrect = correctness === 'correct';
|
|
212
225
|
out.correctChoiceId = normalizedQuestion.correctChoiceId;
|
|
@@ -223,7 +236,7 @@ export const model = async (question: any, session: any, env: any, updateSession
|
|
|
223
236
|
return out;
|
|
224
237
|
};
|
|
225
238
|
|
|
226
|
-
export const createCorrectResponseSession = (question:
|
|
239
|
+
export const createCorrectResponseSession = (question: McpbQuestion, env: McpbEnv) => {
|
|
227
240
|
return new Promise((resolve) => {
|
|
228
241
|
if (env.mode !== 'evaluate' && env.role === 'instructor') {
|
|
229
242
|
resolve({
|
|
@@ -237,18 +250,18 @@ export const createCorrectResponseSession = (question: any, env: any) => {
|
|
|
237
250
|
});
|
|
238
251
|
};
|
|
239
252
|
|
|
240
|
-
export const validate = (
|
|
241
|
-
const errors:
|
|
253
|
+
export const validate = (question: McpbQuestion = {}, _config: Record<string, unknown> = {}) => {
|
|
254
|
+
const errors: Record<string, string> = {};
|
|
242
255
|
|
|
243
|
-
if (
|
|
244
|
-
const p =
|
|
256
|
+
if (question.promptEnabled) {
|
|
257
|
+
const p = question.prompt?.trim() || '';
|
|
245
258
|
if (!p || p === '<p></p>') {
|
|
246
259
|
errors.prompt = 'Prompt is required when prompt is enabled';
|
|
247
260
|
}
|
|
248
261
|
}
|
|
249
262
|
|
|
250
|
-
const interactionMode =
|
|
251
|
-
const template =
|
|
263
|
+
const interactionMode = question.interactionMode || 'populate_blank';
|
|
264
|
+
const template = question.template || '';
|
|
252
265
|
const n = countBlankTokens(template);
|
|
253
266
|
if (interactionMode === 'audio_mc_only') {
|
|
254
267
|
if (n > 0) {
|
|
@@ -264,12 +277,12 @@ export const validate = (model: any = {}, _config: any = {}) => {
|
|
|
264
277
|
errors.interactionMode = 'Unknown interaction mode';
|
|
265
278
|
}
|
|
266
279
|
|
|
267
|
-
const choices = Array.isArray(
|
|
280
|
+
const choices = Array.isArray(question.choices) ? question.choices : [];
|
|
268
281
|
if (choices.length < 2) {
|
|
269
282
|
errors.choices = 'At least two choices are required';
|
|
270
283
|
}
|
|
271
284
|
|
|
272
|
-
const mode =
|
|
285
|
+
const mode = question.choiceMode || 'text';
|
|
273
286
|
for (let i = 0; i < choices.length; i++) {
|
|
274
287
|
const c = choices[i];
|
|
275
288
|
if (!c?.id) {
|
|
@@ -294,19 +307,19 @@ export const validate = (model: any = {}, _config: any = {}) => {
|
|
|
294
307
|
}
|
|
295
308
|
}
|
|
296
309
|
|
|
297
|
-
const correct =
|
|
298
|
-
if (!correct || !choices.some((c
|
|
310
|
+
const correct = question.correctChoiceId;
|
|
311
|
+
if (!correct || !choices.some((c) => c.id === correct)) {
|
|
299
312
|
errors.correctChoiceId = 'Correct choice must match one of the choice ids';
|
|
300
313
|
}
|
|
301
314
|
|
|
302
|
-
if (
|
|
303
|
-
const hasAudioUrl = !!
|
|
315
|
+
if (question.hasAudio) {
|
|
316
|
+
const hasAudioUrl = !!question.audioUrl?.trim();
|
|
304
317
|
if (!hasAudioUrl) {
|
|
305
318
|
errors.audioUrl = 'A playable audio URL is required when audio is enabled';
|
|
306
319
|
}
|
|
307
320
|
}
|
|
308
321
|
|
|
309
|
-
const limits =
|
|
322
|
+
const limits = question?.layoutLimits;
|
|
310
323
|
if (limits && typeof limits === 'object') {
|
|
311
324
|
const numericLimitKeys = [
|
|
312
325
|
'blankStandaloneWidthRem',
|
|
@@ -344,9 +357,9 @@ export const validate = (model: any = {}, _config: any = {}) => {
|
|
|
344
357
|
'inlineGridRowGapRem',
|
|
345
358
|
'inlineTemplateMarginTopRem',
|
|
346
359
|
'inlineChoicesMarginTopRem',
|
|
347
|
-
];
|
|
360
|
+
] as const;
|
|
348
361
|
for (const key of numericLimitKeys) {
|
|
349
|
-
if (limits[key] === undefined || limits[key] === null
|
|
362
|
+
if (limits[key] === undefined || limits[key] === null) continue;
|
|
350
363
|
const value = Number(limits[key]);
|
|
351
364
|
if (!Number.isFinite(value) || value <= 0) {
|
|
352
365
|
errors.layoutLimits = `${key} must be a positive number when provided`;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { computeAudioMode } from './computeAudioMode';
|
|
3
|
+
|
|
4
|
+
interface AudioButtonSkin {
|
|
5
|
+
silentUrl: string;
|
|
6
|
+
playingUrl: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface UiText {
|
|
10
|
+
clickToEnableAutoplay: string;
|
|
11
|
+
transcriptLabel: string;
|
|
12
|
+
audioResourceUnavailable: string;
|
|
13
|
+
listenSilentAlt?: string;
|
|
14
|
+
listenPlayingAlt?: string;
|
|
15
|
+
listenSilentAltEs?: string;
|
|
16
|
+
listenPlayingAltEs?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const AUDIO_PLAYBACK = {
|
|
20
|
+
IDLE: 'idle',
|
|
21
|
+
PLAYING: 'playing',
|
|
22
|
+
BLOCKED: 'blocked',
|
|
23
|
+
PAUSED: 'paused',
|
|
24
|
+
} as const;
|
|
25
|
+
type AudioPlaybackState = (typeof AUDIO_PLAYBACK)[keyof typeof AUDIO_PLAYBACK];
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
hasAudio = false,
|
|
29
|
+
audioUrl,
|
|
30
|
+
useFeatureButtonAudio = false,
|
|
31
|
+
autoplayEnabled = false,
|
|
32
|
+
audioTranscript,
|
|
33
|
+
showVisibleTranscript,
|
|
34
|
+
transcriptId,
|
|
35
|
+
featureAudioSkin,
|
|
36
|
+
uiText,
|
|
37
|
+
locale = '',
|
|
38
|
+
onaudiostarted,
|
|
39
|
+
onaudioended,
|
|
40
|
+
}: {
|
|
41
|
+
hasAudio?: boolean;
|
|
42
|
+
audioUrl?: string;
|
|
43
|
+
useFeatureButtonAudio?: boolean;
|
|
44
|
+
autoplayEnabled?: boolean;
|
|
45
|
+
audioTranscript?: string;
|
|
46
|
+
showVisibleTranscript: boolean;
|
|
47
|
+
transcriptId: string;
|
|
48
|
+
featureAudioSkin: AudioButtonSkin;
|
|
49
|
+
uiText: UiText;
|
|
50
|
+
locale?: string;
|
|
51
|
+
onaudiostarted?: () => void;
|
|
52
|
+
onaudioended?: () => void;
|
|
53
|
+
} = $props();
|
|
54
|
+
|
|
55
|
+
let audioEl = $state<HTMLAudioElement | null>(null);
|
|
56
|
+
let featureAudioButtonEl = $state<HTMLButtonElement | null>(null);
|
|
57
|
+
let autoplayEnableButtonEl = $state<HTMLButtonElement | null>(null);
|
|
58
|
+
let audioPlaybackState = $state<AudioPlaybackState>(AUDIO_PLAYBACK.IDLE);
|
|
59
|
+
|
|
60
|
+
const audioMode = $derived(computeAudioMode({ hasAudio, audioUrl, useFeatureButtonAudio }));
|
|
61
|
+
const isMediaPlaying = $derived(audioPlaybackState === AUDIO_PLAYBACK.PLAYING);
|
|
62
|
+
const autoPlayPromptOpen = $derived(audioPlaybackState === AUDIO_PLAYBACK.BLOCKED);
|
|
63
|
+
const audioErrorMessage = $derived(audioMode === 'error' ? uiText.audioResourceUnavailable : '');
|
|
64
|
+
|
|
65
|
+
// Reset playback state when audioUrl changes so autoplay re-fires for a new question.
|
|
66
|
+
$effect(() => {
|
|
67
|
+
audioUrl;
|
|
68
|
+
audioPlaybackState = AUDIO_PLAYBACK.IDLE;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function playFeatureAudio() {
|
|
72
|
+
if (audioMode !== 'none' && audioMode !== 'error' && audioEl) {
|
|
73
|
+
audioEl.play().catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleEnableAutoplayClick() {
|
|
78
|
+
if (audioMode !== 'none' && audioMode !== 'error' && audioEl) {
|
|
79
|
+
audioEl.play().finally(() => {
|
|
80
|
+
if (audioPlaybackState === AUDIO_PLAYBACK.BLOCKED) audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function useListener<K extends keyof HTMLElementEventMap>(
|
|
86
|
+
getEl: () => EventTarget | null | undefined,
|
|
87
|
+
event: K,
|
|
88
|
+
handler: (e: HTMLElementEventMap[K]) => void
|
|
89
|
+
) {
|
|
90
|
+
$effect(() => {
|
|
91
|
+
const el = getEl();
|
|
92
|
+
if (!el) return;
|
|
93
|
+
el.addEventListener(event, handler as EventListener);
|
|
94
|
+
return () => el.removeEventListener(event, handler as EventListener);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
useListener(
|
|
99
|
+
() => audioEl,
|
|
100
|
+
'playing',
|
|
101
|
+
() => {
|
|
102
|
+
audioPlaybackState = AUDIO_PLAYBACK.PLAYING;
|
|
103
|
+
onaudiostarted?.();
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
useListener(
|
|
108
|
+
() => audioEl,
|
|
109
|
+
'ended',
|
|
110
|
+
() => {
|
|
111
|
+
audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
|
|
112
|
+
onaudioended?.();
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
useListener(
|
|
117
|
+
() => featureAudioButtonEl,
|
|
118
|
+
'click',
|
|
119
|
+
() => playFeatureAudio()
|
|
120
|
+
);
|
|
121
|
+
useListener(
|
|
122
|
+
() => autoplayEnableButtonEl,
|
|
123
|
+
'click',
|
|
124
|
+
() => handleEnableAutoplayClick()
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
$effect(() => {
|
|
128
|
+
if (!autoplayEnabled || audioMode === 'none' || audioMode === 'error') return;
|
|
129
|
+
if (audioPlaybackState !== AUDIO_PLAYBACK.IDLE) return;
|
|
130
|
+
if (audioEl && audioUrl) {
|
|
131
|
+
audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
|
|
132
|
+
audioEl.play().catch(() => {
|
|
133
|
+
audioPlaybackState = AUDIO_PLAYBACK.BLOCKED;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function speechButtonLabel(loc = '') {
|
|
139
|
+
return loc.toLowerCase().startsWith('es') ? 'Escuchar' : 'Listen';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function silentAlt(loc = '') {
|
|
143
|
+
return loc.toLowerCase().startsWith('es')
|
|
144
|
+
? (uiText.listenSilentAltEs ?? 'Escuchar. Repetir las instrucciones.')
|
|
145
|
+
: (uiText.listenSilentAlt ?? 'Repeat instructions');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function playingAlt(loc = '') {
|
|
149
|
+
return loc.toLowerCase().startsWith('es')
|
|
150
|
+
? (uiText.listenPlayingAltEs ?? 'Escuchar. Estas son las instrucciones.')
|
|
151
|
+
: (uiText.listenPlayingAlt ?? 'Instructions are playing');
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
{#if audioMode !== 'none'}
|
|
156
|
+
<div class="mb-4 audio-container pie-audio-container">
|
|
157
|
+
{#if audioMode === 'feature-button'}
|
|
158
|
+
<audio
|
|
159
|
+
bind:this={audioEl}
|
|
160
|
+
class="sr-only pie-audio-player"
|
|
161
|
+
preload="metadata"
|
|
162
|
+
src={audioUrl}
|
|
163
|
+
aria-hidden="true"
|
|
164
|
+
tabindex="-1"
|
|
165
|
+
></audio>
|
|
166
|
+
<button
|
|
167
|
+
bind:this={featureAudioButtonEl}
|
|
168
|
+
class="listen-button pie-listen-button rli-feature-audio"
|
|
169
|
+
type="button"
|
|
170
|
+
aria-label={speechButtonLabel(locale)}
|
|
171
|
+
>
|
|
172
|
+
<img
|
|
173
|
+
class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? '' : 'listen-active'}`}
|
|
174
|
+
src={featureAudioSkin.silentUrl}
|
|
175
|
+
alt={silentAlt(locale)}
|
|
176
|
+
/>
|
|
177
|
+
<img
|
|
178
|
+
class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? 'listen-active' : ''}`}
|
|
179
|
+
src={featureAudioSkin.playingUrl}
|
|
180
|
+
alt={playingAlt(locale)}
|
|
181
|
+
/>
|
|
182
|
+
</button>
|
|
183
|
+
{:else if audioMode === 'controls'}
|
|
184
|
+
<audio
|
|
185
|
+
bind:this={audioEl}
|
|
186
|
+
controls
|
|
187
|
+
class="w-full max-w-md pie-audio-player"
|
|
188
|
+
preload="metadata"
|
|
189
|
+
src={audioUrl}
|
|
190
|
+
aria-describedby={audioTranscript ? transcriptId : undefined}
|
|
191
|
+
>
|
|
192
|
+
<track kind="captions" />
|
|
193
|
+
</audio>
|
|
194
|
+
{#if autoPlayPromptOpen}
|
|
195
|
+
<button
|
|
196
|
+
bind:this={autoplayEnableButtonEl}
|
|
197
|
+
class="mt-2 text-sm underline pie-audio-autoplay-enable"
|
|
198
|
+
type="button"
|
|
199
|
+
>
|
|
200
|
+
{uiText.clickToEnableAutoplay}
|
|
201
|
+
</button>
|
|
202
|
+
{/if}
|
|
203
|
+
{:else if audioMode === 'error'}
|
|
204
|
+
<p class="text-sm text-red-700 pie-audio-error" role="alert">{audioErrorMessage}</p>
|
|
205
|
+
{/if}
|
|
206
|
+
</div>
|
|
207
|
+
{/if}
|
|
208
|
+
|
|
209
|
+
<style>
|
|
210
|
+
.listen-button {
|
|
211
|
+
width: var(--mpb-listen-button-size, 128px);
|
|
212
|
+
height: var(--mpb-listen-button-size, 128px);
|
|
213
|
+
border: 0;
|
|
214
|
+
padding: 0;
|
|
215
|
+
background-color: transparent;
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
z-index: 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.listen-button:hover {
|
|
221
|
+
background-color: #e2f1fe;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.listen-feature-icon {
|
|
225
|
+
width: var(--mpb-listen-button-size, 128px);
|
|
226
|
+
height: var(--mpb-listen-button-size, 128px);
|
|
227
|
+
object-fit: contain;
|
|
228
|
+
display: none;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.listen-feature-icon.listen-active {
|
|
232
|
+
display: block;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.sr-only {
|
|
236
|
+
position: absolute;
|
|
237
|
+
width: 1px;
|
|
238
|
+
height: 1px;
|
|
239
|
+
padding: 0;
|
|
240
|
+
margin: -1px;
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
clip: rect(0, 0, 0, 0);
|
|
243
|
+
white-space: nowrap;
|
|
244
|
+
border: 0;
|
|
245
|
+
}
|
|
246
|
+
</style>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
3
|
+
import AudioPlayer from './AudioPlayer.svelte';
|
|
4
|
+
|
|
5
|
+
const BASE_PROPS = {
|
|
6
|
+
hasAudio: true,
|
|
7
|
+
audioUrl: 'https://example.com/audio.mp3',
|
|
8
|
+
useFeatureButtonAudio: false,
|
|
9
|
+
autoplayEnabled: false,
|
|
10
|
+
audioTranscript: 'The word is look.',
|
|
11
|
+
showVisibleTranscript: false,
|
|
12
|
+
transcriptId: 'test-transcript',
|
|
13
|
+
featureAudioSkin: { silentUrl: '', playingUrl: '' },
|
|
14
|
+
uiText: {
|
|
15
|
+
clickToEnableAutoplay: 'Click to enable',
|
|
16
|
+
transcriptLabel: 'Transcript',
|
|
17
|
+
audioResourceUnavailable: 'Unavailable',
|
|
18
|
+
listenSilentAlt: 'Repeat instructions',
|
|
19
|
+
listenPlayingAlt: 'Instructions are playing',
|
|
20
|
+
listenSilentAltEs: 'Escuchar. Repetir las instrucciones.',
|
|
21
|
+
listenPlayingAltEs: 'Escuchar. Estas son las instrucciones.',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function mountPlayer(props: Partial<typeof BASE_PROPS>) {
|
|
26
|
+
const target = document.createElement('div');
|
|
27
|
+
document.body.appendChild(target);
|
|
28
|
+
const component = mount(AudioPlayer, { target, props: { ...BASE_PROPS, ...props } });
|
|
29
|
+
return { target, component };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const mounts: Array<{ target: HTMLElement; component: ReturnType<typeof mount> }> = [];
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const { target, component } of mounts.splice(0)) {
|
|
36
|
+
unmount(component);
|
|
37
|
+
target.remove();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('AudioPlayer — audio element', () => {
|
|
42
|
+
it('renders native controls when hasAudio=true and useFeatureButtonAudio=false', () => {
|
|
43
|
+
const { target, component } = mountPlayer({ useFeatureButtonAudio: false });
|
|
44
|
+
mounts.push({ target, component });
|
|
45
|
+
flushSync();
|
|
46
|
+
expect(target.querySelector('.pie-audio-player')).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders nothing when hasAudio is false', () => {
|
|
50
|
+
const { target, component } = mountPlayer({ hasAudio: false });
|
|
51
|
+
mounts.push({ target, component });
|
|
52
|
+
flushSync();
|
|
53
|
+
expect(target.querySelector('.pie-audio-container')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders an error message when hasAudio=true but audioUrl is missing', () => {
|
|
57
|
+
const { target, component } = mountPlayer({ audioUrl: undefined });
|
|
58
|
+
mounts.push({ target, component });
|
|
59
|
+
flushSync();
|
|
60
|
+
expect(target.querySelector('.pie-audio-error')).not.toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders the feature button when useFeatureButtonAudio=true', () => {
|
|
64
|
+
const { target, component } = mountPlayer({ useFeatureButtonAudio: true });
|
|
65
|
+
mounts.push({ target, component });
|
|
66
|
+
flushSync();
|
|
67
|
+
expect(target.querySelector('.pie-listen-button')).not.toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('silent image alt is "Repeat instructions" in feature-button mode', () => {
|
|
71
|
+
const { target, component } = mountPlayer({ useFeatureButtonAudio: true });
|
|
72
|
+
mounts.push({ target, component });
|
|
73
|
+
flushSync();
|
|
74
|
+
const imgs = target.querySelectorAll('.pie-listen-icon');
|
|
75
|
+
const alts = Array.from(imgs).map((el) => (el as HTMLImageElement).alt);
|
|
76
|
+
expect(alts).toContain('Repeat instructions');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('playing image alt is "Instructions are playing" in feature-button mode', () => {
|
|
80
|
+
const { target, component } = mountPlayer({ useFeatureButtonAudio: true });
|
|
81
|
+
mounts.push({ target, component });
|
|
82
|
+
flushSync();
|
|
83
|
+
const imgs = target.querySelectorAll('.pie-listen-icon');
|
|
84
|
+
const alts = Array.from(imgs).map((el) => (el as HTMLImageElement).alt);
|
|
85
|
+
expect(alts).toContain('Instructions are playing');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('AudioPlayer — autoplay-blocked prompt', () => {
|
|
90
|
+
it('shows pie-audio-autoplay-enable when autoplay is blocked by the browser', async () => {
|
|
91
|
+
// Override HTMLAudioElement.play on the global so all audio elements reject
|
|
92
|
+
const originalPlay = HTMLMediaElement.prototype.play;
|
|
93
|
+
HTMLMediaElement.prototype.play = () =>
|
|
94
|
+
Promise.reject(new DOMException('autoplay blocked', 'NotAllowedError'));
|
|
95
|
+
|
|
96
|
+
const { target, component } = mountPlayer({
|
|
97
|
+
useFeatureButtonAudio: false,
|
|
98
|
+
autoplayEnabled: true,
|
|
99
|
+
});
|
|
100
|
+
mounts.push({ target, component });
|
|
101
|
+
flushSync();
|
|
102
|
+
|
|
103
|
+
// Allow the rejected promise microtask to settle
|
|
104
|
+
await Promise.resolve();
|
|
105
|
+
flushSync();
|
|
106
|
+
|
|
107
|
+
expect(target.querySelector('.pie-audio-autoplay-enable')).not.toBeNull();
|
|
108
|
+
|
|
109
|
+
HTMLMediaElement.prototype.play = originalPlay;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('hides pie-audio-autoplay-enable when autoplay is not blocked', async () => {
|
|
113
|
+
const originalPlay = HTMLMediaElement.prototype.play;
|
|
114
|
+
HTMLMediaElement.prototype.play = () => Promise.resolve();
|
|
115
|
+
|
|
116
|
+
const { target, component } = mountPlayer({
|
|
117
|
+
useFeatureButtonAudio: false,
|
|
118
|
+
autoplayEnabled: true,
|
|
119
|
+
});
|
|
120
|
+
mounts.push({ target, component });
|
|
121
|
+
flushSync();
|
|
122
|
+
await Promise.resolve();
|
|
123
|
+
flushSync();
|
|
124
|
+
|
|
125
|
+
expect(target.querySelector('.pie-audio-autoplay-enable')).toBeNull();
|
|
126
|
+
|
|
127
|
+
HTMLMediaElement.prototype.play = originalPlay;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('pie-audio-autoplay-enable is not rendered in feature-button mode even when autoplay is blocked', async () => {
|
|
131
|
+
const originalPlay = HTMLMediaElement.prototype.play;
|
|
132
|
+
HTMLMediaElement.prototype.play = () =>
|
|
133
|
+
Promise.reject(new DOMException('autoplay blocked', 'NotAllowedError'));
|
|
134
|
+
|
|
135
|
+
const { target, component } = mountPlayer({
|
|
136
|
+
useFeatureButtonAudio: true,
|
|
137
|
+
autoplayEnabled: true,
|
|
138
|
+
});
|
|
139
|
+
mounts.push({ target, component });
|
|
140
|
+
flushSync();
|
|
141
|
+
await Promise.resolve();
|
|
142
|
+
flushSync();
|
|
143
|
+
|
|
144
|
+
// Feature-button mode never shows the enable-autoplay prompt
|
|
145
|
+
expect(target.querySelector('.pie-audio-autoplay-enable')).toBeNull();
|
|
146
|
+
|
|
147
|
+
HTMLMediaElement.prototype.play = originalPlay;
|
|
148
|
+
});
|
|
149
|
+
});
|