@pie-element/mc-populated-blank 0.2.9 → 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 +20 -10
- 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 +30 -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 +1650 -881
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/variant-css-map.d.ts +10 -0
- package/dist/delivery/variant-css-map.d.ts.map +1 -0
- package/dist/index.iife.d.ts +151 -10
- package/dist/index.iife.d.ts.map +1 -1
- package/dist/index.iife.js +409 -16
- package/dist/index.js +1650 -881
- 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 +64 -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 +234 -709
- 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 +14 -0
- package/src/delivery/cqt-css/sel-r1-gg-plus.css +12 -0
- package/src/delivery/cqt-css/sel-r1-ggplus.css +17 -0
- package/src/delivery/cqt-css/sel-r1-gplusggg.css +25 -0
- package/src/delivery/cqt-css/sel-r1-plusggg.css +17 -0
- package/src/delivery/cqt-css/sel-r1-s3.css +85 -0
- package/src/delivery/cqt-css/sel-vic.css +99 -0
- package/src/delivery/cqt-css/sr-vic.css +63 -0
- package/src/delivery/index.ts +14 -23
- package/src/delivery/variant-css-map.ts +124 -0
- package/src/shared/layoutLimits.ts +76 -0
- package/src/shared/types.ts +84 -0
- package/src/types/css-raw.d.ts +4 -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 || '',
|
|
@@ -195,6 +208,7 @@ export const model = async (question: any, session: any, env: any, updateSession
|
|
|
195
208
|
audioUrl: normalizedQuestion.hasAudio ? normalizedQuestion.audioUrl : null,
|
|
196
209
|
audioTranscript: normalizedQuestion.hasAudio ? normalizedQuestion.audioTranscript : null,
|
|
197
210
|
showVisibleTranscript: !!normalizedQuestion.showVisibleTranscript,
|
|
211
|
+
customType: normalizedQuestion.customType || '',
|
|
198
212
|
useFeatureButtonAudio:
|
|
199
213
|
typeof normalizedQuestion.useFeatureButtonAudio === 'boolean'
|
|
200
214
|
? normalizedQuestion.useFeatureButtonAudio
|
|
@@ -205,7 +219,7 @@ export const model = async (question: any, session: any, env: any, updateSession
|
|
|
205
219
|
};
|
|
206
220
|
|
|
207
221
|
if (safeEnv.mode === 'evaluate') {
|
|
208
|
-
const correctness = getCorrectness(normalizedQuestion,
|
|
222
|
+
const correctness = getCorrectness(normalizedQuestion, safeSession);
|
|
209
223
|
out.correctness = correctness;
|
|
210
224
|
out.responseCorrect = correctness === 'correct';
|
|
211
225
|
out.correctChoiceId = normalizedQuestion.correctChoiceId;
|
|
@@ -222,7 +236,7 @@ export const model = async (question: any, session: any, env: any, updateSession
|
|
|
222
236
|
return out;
|
|
223
237
|
};
|
|
224
238
|
|
|
225
|
-
export const createCorrectResponseSession = (question:
|
|
239
|
+
export const createCorrectResponseSession = (question: McpbQuestion, env: McpbEnv) => {
|
|
226
240
|
return new Promise((resolve) => {
|
|
227
241
|
if (env.mode !== 'evaluate' && env.role === 'instructor') {
|
|
228
242
|
resolve({
|
|
@@ -236,18 +250,18 @@ export const createCorrectResponseSession = (question: any, env: any) => {
|
|
|
236
250
|
});
|
|
237
251
|
};
|
|
238
252
|
|
|
239
|
-
export const validate = (
|
|
240
|
-
const errors:
|
|
253
|
+
export const validate = (question: McpbQuestion = {}, _config: Record<string, unknown> = {}) => {
|
|
254
|
+
const errors: Record<string, string> = {};
|
|
241
255
|
|
|
242
|
-
if (
|
|
243
|
-
const p =
|
|
256
|
+
if (question.promptEnabled) {
|
|
257
|
+
const p = question.prompt?.trim() || '';
|
|
244
258
|
if (!p || p === '<p></p>') {
|
|
245
259
|
errors.prompt = 'Prompt is required when prompt is enabled';
|
|
246
260
|
}
|
|
247
261
|
}
|
|
248
262
|
|
|
249
|
-
const interactionMode =
|
|
250
|
-
const template =
|
|
263
|
+
const interactionMode = question.interactionMode || 'populate_blank';
|
|
264
|
+
const template = question.template || '';
|
|
251
265
|
const n = countBlankTokens(template);
|
|
252
266
|
if (interactionMode === 'audio_mc_only') {
|
|
253
267
|
if (n > 0) {
|
|
@@ -263,12 +277,12 @@ export const validate = (model: any = {}, _config: any = {}) => {
|
|
|
263
277
|
errors.interactionMode = 'Unknown interaction mode';
|
|
264
278
|
}
|
|
265
279
|
|
|
266
|
-
const choices = Array.isArray(
|
|
280
|
+
const choices = Array.isArray(question.choices) ? question.choices : [];
|
|
267
281
|
if (choices.length < 2) {
|
|
268
282
|
errors.choices = 'At least two choices are required';
|
|
269
283
|
}
|
|
270
284
|
|
|
271
|
-
const mode =
|
|
285
|
+
const mode = question.choiceMode || 'text';
|
|
272
286
|
for (let i = 0; i < choices.length; i++) {
|
|
273
287
|
const c = choices[i];
|
|
274
288
|
if (!c?.id) {
|
|
@@ -293,19 +307,19 @@ export const validate = (model: any = {}, _config: any = {}) => {
|
|
|
293
307
|
}
|
|
294
308
|
}
|
|
295
309
|
|
|
296
|
-
const correct =
|
|
297
|
-
if (!correct || !choices.some((c
|
|
310
|
+
const correct = question.correctChoiceId;
|
|
311
|
+
if (!correct || !choices.some((c) => c.id === correct)) {
|
|
298
312
|
errors.correctChoiceId = 'Correct choice must match one of the choice ids';
|
|
299
313
|
}
|
|
300
314
|
|
|
301
|
-
if (
|
|
302
|
-
const hasAudioUrl = !!
|
|
315
|
+
if (question.hasAudio) {
|
|
316
|
+
const hasAudioUrl = !!question.audioUrl?.trim();
|
|
303
317
|
if (!hasAudioUrl) {
|
|
304
318
|
errors.audioUrl = 'A playable audio URL is required when audio is enabled';
|
|
305
319
|
}
|
|
306
320
|
}
|
|
307
321
|
|
|
308
|
-
const limits =
|
|
322
|
+
const limits = question?.layoutLimits;
|
|
309
323
|
if (limits && typeof limits === 'object') {
|
|
310
324
|
const numericLimitKeys = [
|
|
311
325
|
'blankStandaloneWidthRem',
|
|
@@ -343,9 +357,9 @@ export const validate = (model: any = {}, _config: any = {}) => {
|
|
|
343
357
|
'inlineGridRowGapRem',
|
|
344
358
|
'inlineTemplateMarginTopRem',
|
|
345
359
|
'inlineChoicesMarginTopRem',
|
|
346
|
-
];
|
|
360
|
+
] as const;
|
|
347
361
|
for (const key of numericLimitKeys) {
|
|
348
|
-
if (limits[key] === undefined || limits[key] === null
|
|
362
|
+
if (limits[key] === undefined || limits[key] === null) continue;
|
|
349
363
|
const value = Number(limits[key]);
|
|
350
364
|
if (!Number.isFinite(value) || value <= 0) {
|
|
351
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
|
+
});
|