@pie-players/pie-players-shared 0.3.67 → 0.3.68
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 +59 -0
- package/dist/formative/index.d.ts +16 -0
- package/dist/formative/index.js +5 -0
- package/dist/formative/mastery.d.ts +16 -0
- package/dist/formative/mastery.js +43 -0
- package/dist/formative/outcome.d.ts +19 -0
- package/dist/formative/outcome.js +90 -0
- package/dist/formative/policy.d.ts +37 -0
- package/dist/formative/policy.js +86 -0
- package/dist/formative/session.d.ts +19 -0
- package/dist/formative/session.js +113 -0
- package/dist/formative/state.d.ts +60 -0
- package/dist/formative/state.js +140 -0
- package/dist/formative/types.d.ts +164 -0
- package/dist/formative/types.js +13 -0
- package/dist/i18n/catalogs.d.ts +38 -0
- package/dist/i18n/catalogs.js +47 -0
- package/dist/i18n/index.d.ts +28 -4
- package/dist/i18n/index.js +34 -3
- package/dist/i18n/language-tags.d.ts +51 -0
- package/dist/i18n/language-tags.js +84 -0
- package/dist/i18n/messages/en-US.d.ts +601 -0
- package/dist/i18n/messages/en-US.js +617 -0
- package/dist/i18n/messages/nl-NL.d.ts +516 -0
- package/dist/i18n/messages/nl-NL.js +516 -0
- package/dist/i18n/provider.d.ts +168 -0
- package/dist/i18n/provider.js +472 -0
- package/dist/i18n/types.d.ts +177 -28
- package/dist/i18n/types.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/pie/custom-element-define.d.ts +24 -0
- package/dist/pie/custom-element-define.js +36 -6
- package/dist/pie/index.d.ts +1 -1
- package/dist/pie/index.js +1 -1
- package/dist/pie/versioned-tag.js +3 -2
- package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
- package/dist/security/sanitize-forbidden-lists.js +41 -0
- package/dist/security/sanitize-item-markup.js +3 -36
- package/dist/security/sanitize-svg-icon.js +3 -25
- package/dist/security/wrap-overwide-images.d.ts +5 -10
- package/dist/security/wrap-overwide-images.js +13 -62
- package/dist/security/wrap-overwide-tables.d.ts +5 -10
- package/dist/security/wrap-overwide-tables.js +13 -62
- package/dist/security/wrap-overwide.d.ts +51 -0
- package/dist/security/wrap-overwide.js +91 -0
- package/dist/server/npm-auth-env.d.ts +13 -0
- package/dist/server/npm-auth-env.js +64 -0
- package/dist/timed-media/index.d.ts +17 -0
- package/dist/timed-media/index.js +16 -0
- package/dist/timed-media/media-element-source.d.ts +36 -0
- package/dist/timed-media/media-element-source.js +121 -0
- package/dist/timed-media/policy.d.ts +36 -0
- package/dist/timed-media/policy.js +297 -0
- package/dist/timed-media/session.d.ts +21 -0
- package/dist/timed-media/session.js +85 -0
- package/dist/timed-media/state.d.ts +66 -0
- package/dist/timed-media/state.js +357 -0
- package/dist/timed-media/types.d.ts +343 -0
- package/dist/timed-media/types.js +12 -0
- package/dist/tools/term-lookup.d.ts +189 -0
- package/dist/tools/term-lookup.js +284 -0
- package/dist/types/index.d.ts +105 -1
- package/dist/ui/attribute-coercion.d.ts +1 -0
- package/dist/ui/attribute-coercion.js +19 -0
- package/dist/ui/first-focusable.d.ts +22 -0
- package/dist/ui/first-focusable.js +47 -0
- package/dist/ui/focus-trap.js +7 -2
- package/dist/ui/pointer-drag.d.ts +30 -0
- package/dist/ui/pointer-drag.js +34 -0
- package/package.json +35 -3
- package/dist/i18n/loader.d.ts +0 -35
- package/dist/i18n/loader.js +0 -132
- package/dist/i18n/simple-i18n.d.ts +0 -68
- package/dist/i18n/simple-i18n.js +0 -198
- package/dist/i18n/translations/ar/common.json +0 -36
- package/dist/i18n/translations/ar/toolkit.json +0 -48
- package/dist/i18n/translations/ar/tools.json +0 -103
- package/dist/i18n/translations/en/common.json +0 -36
- package/dist/i18n/translations/en/toolkit.json +0 -48
- package/dist/i18n/translations/en/tools.json +0 -103
- package/dist/i18n/translations/es/common.json +0 -36
- package/dist/i18n/translations/es/toolkit.json +0 -48
- package/dist/i18n/translations/es/tools.json +0 -103
- package/dist/i18n/translations/zh/common.json +0 -36
- package/dist/i18n/translations/zh/toolkit.json +0 -48
- package/dist/i18n/translations/zh/tools.json +0 -103
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cue reduction: media position plus delivery state in, cue state and the
|
|
3
|
+
* effects the port must be driven with out.
|
|
4
|
+
*
|
|
5
|
+
* Pure and Node-safe, which is what makes cue policy testable without a browser
|
|
6
|
+
* and what keeps it out of every layout that hosts timed media. The caller owns
|
|
7
|
+
* the port and applies the effects; nothing here touches media.
|
|
8
|
+
*/
|
|
9
|
+
export const TIMED_MEDIA_SLICE_VERSION = 1;
|
|
10
|
+
/**
|
|
11
|
+
* How far past the furthest reached position a seek may land before it is
|
|
12
|
+
* clamped. A native element reports `currentTime` from `timeupdate`, which fires
|
|
13
|
+
* about four times a second, so the furthest recorded position trails real
|
|
14
|
+
* playback by up to a quarter second; clamping at exactly the recorded value
|
|
15
|
+
* would fight a learner nudging the scrubber where they already are.
|
|
16
|
+
*/
|
|
17
|
+
const SEEK_AHEAD_TOLERANCE_SECONDS = 0.5;
|
|
18
|
+
/** Media positions are compared in seconds; a millisecond is below resolution. */
|
|
19
|
+
const TIME_EPSILON_SECONDS = 1e-3;
|
|
20
|
+
/**
|
|
21
|
+
* How much new ground playback covers before the furthest position is worth
|
|
22
|
+
* republishing for.
|
|
23
|
+
*
|
|
24
|
+
* Position renders nowhere, so this exists only to bound what a reload hands back:
|
|
25
|
+
* `allowSeekAhead: false` clamps against the persisted `maxPositionSeconds`, and a
|
|
26
|
+
* section whose next cue is minutes away would otherwise persist nothing in
|
|
27
|
+
* between.
|
|
28
|
+
*/
|
|
29
|
+
const POSITION_PERSIST_BUCKET_SECONDS = 10;
|
|
30
|
+
export function createTimedMediaState() {
|
|
31
|
+
return {
|
|
32
|
+
version: TIMED_MEDIA_SLICE_VERSION,
|
|
33
|
+
mediaCurrentTime: 0,
|
|
34
|
+
maxPositionSeconds: 0,
|
|
35
|
+
mediaCompleted: false,
|
|
36
|
+
visitedCueIdentifiers: [],
|
|
37
|
+
completedCueIdentifiers: [],
|
|
38
|
+
aggregateComplete: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function toFiniteSeconds(value, fallback) {
|
|
42
|
+
const numeric = Number(value);
|
|
43
|
+
if (!Number.isFinite(numeric) || numeric < 0)
|
|
44
|
+
return fallback;
|
|
45
|
+
return numeric;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A cue is reached once playback has passed its start, and stays reached.
|
|
49
|
+
*
|
|
50
|
+
* The end bound does not un-reach it, and seeking past a cue's whole window still
|
|
51
|
+
* reaches it. That is deliberate: the alternative lets a learner who is allowed
|
|
52
|
+
* to seek ahead jump over a checkpoint the author wrote, and a gate that can be
|
|
53
|
+
* skipped is not a gate.
|
|
54
|
+
*/
|
|
55
|
+
function isCueReached(cue, seconds) {
|
|
56
|
+
return seconds + TIME_EPSILON_SECONDS >= cue.range.startSeconds;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether one item satisfies a gate condition.
|
|
60
|
+
*
|
|
61
|
+
* `undefined` correctness is "no Try yet" and never satisfies a correctness
|
|
62
|
+
* condition. `"unknown"` is an item no loaded controller can score, and the
|
|
63
|
+
* authored `onUnknownCorrectness` decides it — never collapsed into
|
|
64
|
+
* `"incorrect"`, which would hold a learner behind an item nothing can release.
|
|
65
|
+
*/
|
|
66
|
+
function itemSatisfiesGate(cue, itemId, delivery) {
|
|
67
|
+
if (cue.releaseOn === "responded") {
|
|
68
|
+
return delivery.respondedByItemId[itemId] === true;
|
|
69
|
+
}
|
|
70
|
+
const correctness = delivery.correctnessByItemId[itemId];
|
|
71
|
+
if (correctness === undefined)
|
|
72
|
+
return false;
|
|
73
|
+
if (correctness === "unknown")
|
|
74
|
+
return cue.onUnknownCorrectness === "release";
|
|
75
|
+
if (cue.releaseOn === "correct")
|
|
76
|
+
return correctness === "correct";
|
|
77
|
+
return correctness === "correct" || correctness === "partial";
|
|
78
|
+
}
|
|
79
|
+
/** Every item a gate names has to satisfy it; one unanswered item still holds. */
|
|
80
|
+
function isGateReleased(cue, delivery) {
|
|
81
|
+
if (cue.itemRefs.length === 0)
|
|
82
|
+
return true;
|
|
83
|
+
return cue.itemRefs.every((itemId) => itemSatisfiesGate(cue, itemId, delivery));
|
|
84
|
+
}
|
|
85
|
+
function sameStringList(left, right) {
|
|
86
|
+
if (left.length !== right.length)
|
|
87
|
+
return false;
|
|
88
|
+
return left.every((value, index) => value === right[index]);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Advance cue state.
|
|
92
|
+
*
|
|
93
|
+
* Two things are monotonic by design. A cue stays visited and its items stay
|
|
94
|
+
* revealed once reached, because un-revealing an item a learner has already
|
|
95
|
+
* answered would take a response off the screen. And a released gate stays
|
|
96
|
+
* complete, so a retry that lowers correctness cannot re-trap a learner
|
|
97
|
+
* mid-playback — the same reasoning that keeps `firstCorrectTry` from being
|
|
98
|
+
* overwritten by a later incorrect Try.
|
|
99
|
+
*/
|
|
100
|
+
export function reduceTimedMediaState(args) {
|
|
101
|
+
const { state, data, delivery, input } = args;
|
|
102
|
+
const effects = { pause: false };
|
|
103
|
+
let nextTime = state.mediaCurrentTime;
|
|
104
|
+
let mediaCompleted = state.mediaCompleted;
|
|
105
|
+
switch (input.kind) {
|
|
106
|
+
case "time":
|
|
107
|
+
nextTime = toFiniteSeconds(input.currentTimeSeconds, state.mediaCurrentTime);
|
|
108
|
+
break;
|
|
109
|
+
case "seek": {
|
|
110
|
+
const requested = toFiniteSeconds(input.currentTimeSeconds, state.mediaCurrentTime);
|
|
111
|
+
if (!data.playbackPolicy.allowSeekAhead &&
|
|
112
|
+
requested >
|
|
113
|
+
state.maxPositionSeconds + SEEK_AHEAD_TOLERANCE_SECONDS) {
|
|
114
|
+
effects.seekToSeconds = state.maxPositionSeconds;
|
|
115
|
+
nextTime = state.maxPositionSeconds;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
nextTime = requested;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case "ended":
|
|
123
|
+
nextTime = toFiniteSeconds(input.currentTimeSeconds, state.mediaCurrentTime);
|
|
124
|
+
mediaCompleted = true;
|
|
125
|
+
break;
|
|
126
|
+
case "delivery-changed":
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
const maxPositionSeconds = Math.max(state.maxPositionSeconds, nextTime);
|
|
130
|
+
const visitedCueIdentifiers = [...state.visitedCueIdentifiers];
|
|
131
|
+
const visitedSet = new Set(visitedCueIdentifiers);
|
|
132
|
+
for (const cue of data.cues) {
|
|
133
|
+
if (visitedSet.has(cue.identifier))
|
|
134
|
+
continue;
|
|
135
|
+
if (!isCueReached(cue, nextTime))
|
|
136
|
+
continue;
|
|
137
|
+
visitedSet.add(cue.identifier);
|
|
138
|
+
visitedCueIdentifiers.push(cue.identifier);
|
|
139
|
+
// Only the last activation of a reduction is reported: a seek that crosses
|
|
140
|
+
// several cues at once has one place for focus to go, and that is the
|
|
141
|
+
// furthest one, which is where the learner now is.
|
|
142
|
+
effects.activatedCueIdentifier = cue.identifier;
|
|
143
|
+
}
|
|
144
|
+
const completedSet = new Set(state.completedCueIdentifiers);
|
|
145
|
+
let holdingGate = null;
|
|
146
|
+
for (const cue of data.cues) {
|
|
147
|
+
if (!visitedSet.has(cue.identifier))
|
|
148
|
+
continue;
|
|
149
|
+
if (completedSet.has(cue.identifier))
|
|
150
|
+
continue;
|
|
151
|
+
if (!cue.holdsPlayback) {
|
|
152
|
+
// A reveal or metadata cue has no condition to satisfy, and a gate under
|
|
153
|
+
// `pauseOnRequiredCue: false` has had its hold switched off wholesale.
|
|
154
|
+
completedSet.add(cue.identifier);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (isGateReleased(cue, delivery)) {
|
|
158
|
+
completedSet.add(cue.identifier);
|
|
159
|
+
effects.releasedCueIdentifier = cue.identifier;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Authored order decides which of two open gates holds, and only the first
|
|
163
|
+
// one does — playback is stopped either way, and reporting both would give
|
|
164
|
+
// the layout two places to send focus.
|
|
165
|
+
if (!holdingGate)
|
|
166
|
+
holdingGate = cue;
|
|
167
|
+
}
|
|
168
|
+
const completedCueIdentifiers = data.cues
|
|
169
|
+
.map((cue) => cue.identifier)
|
|
170
|
+
.filter((identifier) => completedSet.has(identifier));
|
|
171
|
+
effects.pause = holdingGate !== null;
|
|
172
|
+
const activeCueIdentifier = holdingGate?.identifier ??
|
|
173
|
+
visitedCueIdentifiers[visitedCueIdentifiers.length - 1];
|
|
174
|
+
const aggregateComplete = completedCueIdentifiers.length === data.cues.length &&
|
|
175
|
+
delivery.itemsComplete &&
|
|
176
|
+
(data.playbackPolicy.requireMediaCompletion ? mediaCompleted : true);
|
|
177
|
+
const next = {
|
|
178
|
+
version: TIMED_MEDIA_SLICE_VERSION,
|
|
179
|
+
mediaCurrentTime: nextTime,
|
|
180
|
+
maxPositionSeconds,
|
|
181
|
+
mediaCompleted,
|
|
182
|
+
visitedCueIdentifiers,
|
|
183
|
+
completedCueIdentifiers,
|
|
184
|
+
activeCueIdentifier,
|
|
185
|
+
aggregateComplete,
|
|
186
|
+
};
|
|
187
|
+
const changed = next.mediaCurrentTime !== state.mediaCurrentTime ||
|
|
188
|
+
next.maxPositionSeconds !== state.maxPositionSeconds ||
|
|
189
|
+
next.mediaCompleted !== state.mediaCompleted ||
|
|
190
|
+
next.activeCueIdentifier !== state.activeCueIdentifier ||
|
|
191
|
+
next.aggregateComplete !== state.aggregateComplete ||
|
|
192
|
+
!sameStringList(next.visitedCueIdentifiers, state.visitedCueIdentifiers) ||
|
|
193
|
+
!sameStringList(next.completedCueIdentifiers, state.completedCueIdentifiers);
|
|
194
|
+
return { state: changed ? next : state, effects, changed };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Which policies the attached port can actually carry out.
|
|
198
|
+
*
|
|
199
|
+
* Absent a port there is nothing to degrade *from* yet, so an unattached section
|
|
200
|
+
* reports its authored intent rather than a capability gap it has not measured.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveTimedMediaEnforcement(args) {
|
|
203
|
+
const degradations = [];
|
|
204
|
+
const wantsPause = args.playbackPolicy.pauseOnRequiredCue && args.hasGate;
|
|
205
|
+
const wantsSeekLock = !args.playbackPolicy.allowSeekAhead;
|
|
206
|
+
const canPause = args.capabilities ? args.capabilities.canPause : true;
|
|
207
|
+
const canRestrictSeeking = args.capabilities
|
|
208
|
+
? args.capabilities.canRestrictSeeking
|
|
209
|
+
: true;
|
|
210
|
+
if (wantsPause && !canPause) {
|
|
211
|
+
degradations.push({
|
|
212
|
+
policy: "pause-on-required-cue",
|
|
213
|
+
capability: "canPause",
|
|
214
|
+
message: "The media time source cannot pause, so gate cues are advisory: they fire and record state, and playback continues.",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (wantsSeekLock && !canRestrictSeeking) {
|
|
218
|
+
degradations.push({
|
|
219
|
+
policy: "restrict-seek-ahead",
|
|
220
|
+
capability: "canRestrictSeeking",
|
|
221
|
+
message: "The media time source cannot restrict seeking, so allowSeekAhead: false is advisory: a learner can seek past cues.",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
pause: wantsPause && !canPause ? "advisory" : "enforced",
|
|
226
|
+
seek: wantsSeekLock && !canRestrictSeeking ? "advisory" : "enforced",
|
|
227
|
+
degradations,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** The gate a layout renders, or `null` when nothing is holding. */
|
|
231
|
+
function resolveGateView(args) {
|
|
232
|
+
const visited = new Set(args.state.visitedCueIdentifiers);
|
|
233
|
+
const completed = new Set(args.state.completedCueIdentifiers);
|
|
234
|
+
for (const cue of args.data.cues) {
|
|
235
|
+
if (!cue.holdsPlayback || !cue.releaseOn)
|
|
236
|
+
continue;
|
|
237
|
+
if (!visited.has(cue.identifier))
|
|
238
|
+
continue;
|
|
239
|
+
if (completed.has(cue.identifier))
|
|
240
|
+
continue;
|
|
241
|
+
return {
|
|
242
|
+
cueIdentifier: cue.identifier,
|
|
243
|
+
itemRefs: [...cue.itemRefs],
|
|
244
|
+
releaseOn: cue.releaseOn,
|
|
245
|
+
holding: !isGateReleased(cue, args.delivery),
|
|
246
|
+
enforcement: args.pauseEnforcement,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Whether a cue decides when its items are delivered.
|
|
253
|
+
*
|
|
254
|
+
* A `metadata` cue does not: it records state and reveals nothing, so the items it
|
|
255
|
+
* names stay ordinary items. One predicate for both id sets below, because a cue
|
|
256
|
+
* counted as sequencing but never as revealing hides its items for the whole
|
|
257
|
+
* section.
|
|
258
|
+
*/
|
|
259
|
+
function cueSequencesDelivery(cue) {
|
|
260
|
+
return cue.activation !== "metadata";
|
|
261
|
+
}
|
|
262
|
+
/** Canonical ids of every item the timeline sequences, in cue order. */
|
|
263
|
+
function resolveSequencedItemIds(data) {
|
|
264
|
+
const sequenced = [];
|
|
265
|
+
for (const cue of data.cues) {
|
|
266
|
+
if (!cueSequencesDelivery(cue))
|
|
267
|
+
continue;
|
|
268
|
+
for (const itemId of cue.itemRefs) {
|
|
269
|
+
if (!sequenced.includes(itemId))
|
|
270
|
+
sequenced.push(itemId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return sequenced;
|
|
274
|
+
}
|
|
275
|
+
/** Canonical ids of items a visited cue has revealed, in cue order. */
|
|
276
|
+
function resolveRevealedItemIds(data, state) {
|
|
277
|
+
const visited = new Set(state.visitedCueIdentifiers);
|
|
278
|
+
const revealed = [];
|
|
279
|
+
for (const cue of data.cues) {
|
|
280
|
+
if (!cueSequencesDelivery(cue))
|
|
281
|
+
continue;
|
|
282
|
+
if (!visited.has(cue.identifier))
|
|
283
|
+
continue;
|
|
284
|
+
for (const itemId of cue.itemRefs) {
|
|
285
|
+
if (!revealed.includes(itemId))
|
|
286
|
+
revealed.push(itemId);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return revealed;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Derive the view layouts read. Recomputed from state on every republish rather
|
|
293
|
+
* than stored, so there is one source of truth and no mirror to go stale.
|
|
294
|
+
*/
|
|
295
|
+
export function resolveTimedMediaProjection(args) {
|
|
296
|
+
const hasGate = args.data.cues.some((cue) => cue.activation === "gate");
|
|
297
|
+
const enforcement = resolveTimedMediaEnforcement({
|
|
298
|
+
playbackPolicy: args.data.playbackPolicy,
|
|
299
|
+
capabilities: args.capabilities,
|
|
300
|
+
hasGate,
|
|
301
|
+
});
|
|
302
|
+
return {
|
|
303
|
+
version: 1,
|
|
304
|
+
stimulusRenderableId: args.data.stimulusRenderableId,
|
|
305
|
+
cues: args.data.cues,
|
|
306
|
+
playbackPolicy: args.data.playbackPolicy,
|
|
307
|
+
mediaAttached: args.capabilities !== null,
|
|
308
|
+
enforcement: { pause: enforcement.pause, seek: enforcement.seek },
|
|
309
|
+
degradations: enforcement.degradations,
|
|
310
|
+
revealedItemIds: resolveRevealedItemIds(args.data, args.state),
|
|
311
|
+
sequencedItemIds: resolveSequencedItemIds(args.data),
|
|
312
|
+
activeCueIdentifier: args.state.activeCueIdentifier,
|
|
313
|
+
gate: resolveGateView({
|
|
314
|
+
data: args.data,
|
|
315
|
+
state: args.state,
|
|
316
|
+
delivery: args.delivery,
|
|
317
|
+
pauseEnforcement: enforcement.pause,
|
|
318
|
+
}),
|
|
319
|
+
visitedCueIdentifiers: [...args.state.visitedCueIdentifiers],
|
|
320
|
+
completedCueIdentifiers: [...args.state.completedCueIdentifiers],
|
|
321
|
+
mediaCurrentTime: args.state.mediaCurrentTime,
|
|
322
|
+
maxPositionSeconds: args.state.maxPositionSeconds,
|
|
323
|
+
mediaCompleted: args.state.mediaCompleted,
|
|
324
|
+
aggregateComplete: args.state.aggregateComplete === true,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The projection facts that change what a layout renders, as one string.
|
|
329
|
+
*
|
|
330
|
+
* One implementation for the two callers that have to agree: `SectionController`
|
|
331
|
+
* decides whether to emit on it, and the toolkit folds it into the composition
|
|
332
|
+
* revision key. Two encodings drift silently in either direction — the controller
|
|
333
|
+
* emits a change the toolkit coalesces away, or the toolkit republishes for a
|
|
334
|
+
* change the controller never announced.
|
|
335
|
+
*
|
|
336
|
+
* `mediaCurrentTime` is absent because it moves about four times a second and
|
|
337
|
+
* nothing renders it. `maxPositionSeconds` enters only as a coarse bucket; see
|
|
338
|
+
* `POSITION_PERSIST_BUCKET_SECONDS`.
|
|
339
|
+
*/
|
|
340
|
+
export function timedMediaProjectionSignature(projection) {
|
|
341
|
+
if (!projection)
|
|
342
|
+
return "";
|
|
343
|
+
return [
|
|
344
|
+
(projection.visitedCueIdentifiers ?? []).join(","),
|
|
345
|
+
(projection.completedCueIdentifiers ?? []).join(","),
|
|
346
|
+
(projection.revealedItemIds ?? []).join(","),
|
|
347
|
+
projection.activeCueIdentifier ?? "",
|
|
348
|
+
projection.gate?.cueIdentifier ?? "",
|
|
349
|
+
projection.gate?.holding === true ? "1" : "0",
|
|
350
|
+
projection.enforcement?.pause ?? "",
|
|
351
|
+
projection.enforcement?.seek ?? "",
|
|
352
|
+
projection.mediaAttached === true ? "1" : "0",
|
|
353
|
+
projection.mediaCompleted === true ? "1" : "0",
|
|
354
|
+
projection.aggregateComplete === true ? "1" : "0",
|
|
355
|
+
String(Math.floor((projection.maxPositionSeconds ?? 0) / POSITION_PERSIST_BUCKET_SECONDS)),
|
|
356
|
+
].join(":");
|
|
357
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timed-media section vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A **Cue** is a window on a media timeline that activates one or more of the
|
|
5
|
+
* section's item refs. A **Media Time Source** is the port the section reaches
|
|
6
|
+
* media through — never a library API, so a host can supply its own media
|
|
7
|
+
* element without shipping a PIE element.
|
|
8
|
+
*
|
|
9
|
+
* See `docs/prds/timed-media-section-contract.md` for the ratified contract and
|
|
10
|
+
* `docs/architecture/timed-media-section.md` for the layer-ownership record.
|
|
11
|
+
*/
|
|
12
|
+
import type { FormativeCorrectness } from "../formative/types.js";
|
|
13
|
+
import type { MediaFragmentRange } from "../types/index.js";
|
|
14
|
+
/**
|
|
15
|
+
* What a cue does to the items it names.
|
|
16
|
+
*
|
|
17
|
+
* - `reveal`: the items become visible when the media reaches the cue.
|
|
18
|
+
* - `gate`: as `reveal`, and playback is held until the release condition holds.
|
|
19
|
+
* - `metadata`: state and events only — an author-visible timeline marker that
|
|
20
|
+
* reveals nothing and gates nothing.
|
|
21
|
+
*/
|
|
22
|
+
export type TimedMediaCueActivation = "reveal" | "gate" | "metadata";
|
|
23
|
+
/**
|
|
24
|
+
* The condition over delivery state that releases a gate.
|
|
25
|
+
*
|
|
26
|
+
* Named from the shipped formative vocabulary rather than defined here, which is
|
|
27
|
+
* the whole reason formative delivery sequenced first — see
|
|
28
|
+
* `docs/adr/0001-formative-delivery-before-timed-media.md`. `"responded"` is the
|
|
29
|
+
* response-only case, and it reads item completion rather than Try state so a
|
|
30
|
+
* gate works in a section that does not deliver formatively at all.
|
|
31
|
+
*/
|
|
32
|
+
export type TimedMediaGateCondition = "responded" | "correct" | "partial-or-better";
|
|
33
|
+
/**
|
|
34
|
+
* What a gate does when the item cannot be auto-scored — `FormativeCorrectness`
|
|
35
|
+
* of `"unknown"`, which is the honest state of an item holding a rubric element
|
|
36
|
+
* rather than a defect to design around.
|
|
37
|
+
*
|
|
38
|
+
* Required on a gate whose condition is a correctness condition; see
|
|
39
|
+
* `normalizeTimedMediaSectionData`. Not defaulted, because both answers are
|
|
40
|
+
* defensible and the wrong silent one either traps a learner behind an item
|
|
41
|
+
* nothing can score or waves through the checkpoint the author wrote.
|
|
42
|
+
*/
|
|
43
|
+
export type TimedMediaUnknownCorrectness = "release" | "hold";
|
|
44
|
+
export interface TimedMediaCuePolicy {
|
|
45
|
+
activation: TimedMediaCueActivation;
|
|
46
|
+
/** Required for `activation: "gate"`; ignored otherwise. */
|
|
47
|
+
releaseOn?: TimedMediaGateCondition;
|
|
48
|
+
/** Required when `releaseOn` names correctness; ignored otherwise. */
|
|
49
|
+
onUnknownCorrectness?: TimedMediaUnknownCorrectness;
|
|
50
|
+
}
|
|
51
|
+
export interface TimedMediaCue {
|
|
52
|
+
identifier: string;
|
|
53
|
+
/**
|
|
54
|
+
* The window in which this cue is active, reusing the ratified range type. A
|
|
55
|
+
* point cue omits `endSeconds`.
|
|
56
|
+
*
|
|
57
|
+
* Carried beside the asset rather than inside `MediaAssetRef` because a range
|
|
58
|
+
* describes a *use* of an asset. Here that use is activation, not a slice to
|
|
59
|
+
* play, so it implies no seeking — the opposite of what the two shipped
|
|
60
|
+
* catalog consumers read the same shape as.
|
|
61
|
+
*/
|
|
62
|
+
range: MediaFragmentRange;
|
|
63
|
+
/** Identifiers of this section's `assessmentItemRefs`. */
|
|
64
|
+
itemRefs: string[];
|
|
65
|
+
policy: TimedMediaCuePolicy;
|
|
66
|
+
}
|
|
67
|
+
export interface TimedMediaPlaybackPolicy {
|
|
68
|
+
/** `false` clamps a forward seek to the furthest position already reached. */
|
|
69
|
+
allowSeekAhead: boolean;
|
|
70
|
+
/** `false` turns every `gate` cue into a `reveal`: nothing holds playback. */
|
|
71
|
+
pauseOnRequiredCue: boolean;
|
|
72
|
+
/** Whether `aggregateComplete` requires the media to have ended. */
|
|
73
|
+
requireMediaCompletion: boolean;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Authored scoring intent. Accepted, validated and persisted; PIE computes no
|
|
77
|
+
* aggregate outcome from it yet and supplies no default, so a section that omits
|
|
78
|
+
* it is not silently assigned one.
|
|
79
|
+
*
|
|
80
|
+
* No `weighted-child-outcomes`: no weight is authorable on a cue, an item ref or
|
|
81
|
+
* the section, so the strategy would name a capability PIE does not have. Where
|
|
82
|
+
* weights live is the score contract's question, and a host that already has its
|
|
83
|
+
* own weights says `host-defined`.
|
|
84
|
+
*/
|
|
85
|
+
export interface TimedMediaScoringPolicy {
|
|
86
|
+
strategy: "sum-child-outcomes" | "average-child-outcomes" | "host-defined";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The authored `timedMedia` payload, carried on `AssessmentSection`.
|
|
90
|
+
*
|
|
91
|
+
* No media payload: `stimulusRef` names the renderable that supplies the time
|
|
92
|
+
* source, authored as a `class: "stimulus"` rubric block whose passage config
|
|
93
|
+
* mounts the media element. The passage owns the asset and its accessibility
|
|
94
|
+
* catalogs, because a passage is a Catalog Owner and a media blob is not.
|
|
95
|
+
*/
|
|
96
|
+
export interface TimedMediaSectionData {
|
|
97
|
+
/**
|
|
98
|
+
* The rubric block identifier or passage id of the stimulus renderable.
|
|
99
|
+
* Required, and validated to resolve within this section: a section carrying
|
|
100
|
+
* cues with no resolvable stimulus is malformed, because the alternative is
|
|
101
|
+
* cues that silently never fire.
|
|
102
|
+
*/
|
|
103
|
+
stimulusRef: string;
|
|
104
|
+
cues: TimedMediaCue[];
|
|
105
|
+
playbackPolicy: TimedMediaPlaybackPolicy;
|
|
106
|
+
scoringPolicy?: TimedMediaScoringPolicy;
|
|
107
|
+
}
|
|
108
|
+
/** A cue after validation, with every optional field decided. */
|
|
109
|
+
export interface ResolvedTimedMediaCue {
|
|
110
|
+
identifier: string;
|
|
111
|
+
range: MediaFragmentRange;
|
|
112
|
+
/** Filtered to item refs this section actually holds. */
|
|
113
|
+
itemRefs: string[];
|
|
114
|
+
activation: TimedMediaCueActivation;
|
|
115
|
+
/** Present only for a cue that holds playback. */
|
|
116
|
+
releaseOn?: TimedMediaGateCondition;
|
|
117
|
+
onUnknownCorrectness?: TimedMediaUnknownCorrectness;
|
|
118
|
+
/**
|
|
119
|
+
* Whether this cue holds playback: `activation: "gate"` and a playback policy
|
|
120
|
+
* that has not switched gating off wholesale.
|
|
121
|
+
*/
|
|
122
|
+
holdsPlayback: boolean;
|
|
123
|
+
}
|
|
124
|
+
/** The authored data after validation. */
|
|
125
|
+
export interface ResolvedTimedMediaSectionData {
|
|
126
|
+
stimulusRef: string;
|
|
127
|
+
/** The renderable id the stimulus resolved to, which the layout renders. */
|
|
128
|
+
stimulusRenderableId: string;
|
|
129
|
+
cues: ResolvedTimedMediaCue[];
|
|
130
|
+
playbackPolicy: TimedMediaPlaybackPolicy;
|
|
131
|
+
scoringPolicy?: TimedMediaScoringPolicy;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What a gated item's resolved formative policy leaves the learner.
|
|
135
|
+
*
|
|
136
|
+
* Read by validation, not by the reduction: a gate on correctness over an item
|
|
137
|
+
* with a finite Try budget is a dead end, and so is one over an item that does not
|
|
138
|
+
* deliver formatively at all — no Try means no correctness, ever.
|
|
139
|
+
*/
|
|
140
|
+
export type TimedMediaItemTryBudget = "unlimited" | "finite" | "not-formative";
|
|
141
|
+
export interface TimedMediaValidationError {
|
|
142
|
+
/** Machine-readable, so a host can branch without parsing prose. */
|
|
143
|
+
code: "missing-stimulus-ref" | "unresolved-stimulus-ref"
|
|
144
|
+
/**
|
|
145
|
+
* Reported at runtime rather than by validation: `stimulusRef` resolved, but
|
|
146
|
+
* the renderable it names exposed no Media Time Source, so no cue can fire.
|
|
147
|
+
* Whether a renderable mounts media is not knowable from authored data.
|
|
148
|
+
*/
|
|
149
|
+
| "stimulus-exposes-no-time-source" | "no-cues" | "duplicate-cue-identifier" | "invalid-cue-identifier" | "invalid-cue-range" | "invalid-cue-activation" | "unknown-item-ref" | "missing-item-refs" | "missing-release-condition" | "missing-unknown-correctness" | "gate-requires-unlimited-tries" | "invalid-playback-policy" | "invalid-scoring-policy";
|
|
150
|
+
message: string;
|
|
151
|
+
/** The cue the error is about, where it is about one. */
|
|
152
|
+
cueIdentifier?: string;
|
|
153
|
+
}
|
|
154
|
+
export interface TimedMediaValidationResult {
|
|
155
|
+
data: ResolvedTimedMediaSectionData | null;
|
|
156
|
+
errors: TimedMediaValidationError[];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* What the adapter can actually do, as opposed to what the section would like.
|
|
160
|
+
*
|
|
161
|
+
* Declared by the port rather than assumed by the section: a third-party embed
|
|
162
|
+
* commonly exposes time and nothing else. Where a capability is missing the
|
|
163
|
+
* matching policy degrades from enforced to advisory — cues still fire and state
|
|
164
|
+
* is still recorded — and the degradation is reported as a recoverable framework
|
|
165
|
+
* warning, because a seek lock that does not lock reads to an author as one that
|
|
166
|
+
* does.
|
|
167
|
+
*/
|
|
168
|
+
export interface MediaTimeSourceCapabilities {
|
|
169
|
+
canPause: boolean;
|
|
170
|
+
canRestrictSeeking: boolean;
|
|
171
|
+
}
|
|
172
|
+
/** Structurally `TimeRanges`, so a native media element satisfies it as-is. */
|
|
173
|
+
export interface MediaTimeRanges {
|
|
174
|
+
readonly length: number;
|
|
175
|
+
start(index: number): number;
|
|
176
|
+
end(index: number): number;
|
|
177
|
+
}
|
|
178
|
+
export type MediaTimeSourceNotification = {
|
|
179
|
+
type: "time";
|
|
180
|
+
currentTime: number;
|
|
181
|
+
} | {
|
|
182
|
+
type: "seek";
|
|
183
|
+
currentTime: number;
|
|
184
|
+
} | {
|
|
185
|
+
type: "play";
|
|
186
|
+
currentTime: number;
|
|
187
|
+
} | {
|
|
188
|
+
type: "pause";
|
|
189
|
+
currentTime: number;
|
|
190
|
+
} | {
|
|
191
|
+
type: "ended";
|
|
192
|
+
currentTime: number;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* The only way the section reaches media.
|
|
196
|
+
*
|
|
197
|
+
* Shaped after `HTMLMediaElement` deliberately: the browser's own surface is the
|
|
198
|
+
* one every adapter author already knows, and a native `<video>` satisfies it
|
|
199
|
+
* through the few lines in `createMediaElementTimeSource`. That is what keeps the
|
|
200
|
+
* media-player dependency question reversible instead of load-bearing.
|
|
201
|
+
*
|
|
202
|
+
* Two departures from the element, both to make capability honest. Seeking is
|
|
203
|
+
* `seekTo(seconds)` rather than a writable `currentTime`, because a writable
|
|
204
|
+
* property gives a source that cannot seek no way to say so; and `capabilities`
|
|
205
|
+
* has no element equivalent at all.
|
|
206
|
+
*/
|
|
207
|
+
export interface MediaTimeSource {
|
|
208
|
+
readonly currentTime: number;
|
|
209
|
+
/** `NaN` until metadata loads, as on the element. */
|
|
210
|
+
readonly duration: number;
|
|
211
|
+
readonly paused: boolean;
|
|
212
|
+
readonly seekable: MediaTimeRanges | null;
|
|
213
|
+
readonly capabilities: MediaTimeSourceCapabilities;
|
|
214
|
+
play(): Promise<void> | void;
|
|
215
|
+
pause(): void;
|
|
216
|
+
seekTo(seconds: number): void;
|
|
217
|
+
subscribe(listener: (notification: MediaTimeSourceNotification) => void): () => void;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The persisted slice, carried on `SectionControllerSessionState.timedMedia`.
|
|
221
|
+
*
|
|
222
|
+
* One shape live and persisted, unlike formative's split between a per-item
|
|
223
|
+
* reducer state and a slice: timed-media state is section-scoped, so there is
|
|
224
|
+
* nothing to key.
|
|
225
|
+
*/
|
|
226
|
+
export interface TimedMediaSectionSessionSlice {
|
|
227
|
+
version: 1;
|
|
228
|
+
mediaCurrentTime: number;
|
|
229
|
+
/**
|
|
230
|
+
* Furthest position reached, which is what `allowSeekAhead: false` clamps
|
|
231
|
+
* against. Persisted rather than derived because a reload would otherwise hand
|
|
232
|
+
* the learner the whole timeline back — an addition to the contract's session
|
|
233
|
+
* sketch, recorded in the PRD.
|
|
234
|
+
*/
|
|
235
|
+
maxPositionSeconds: number;
|
|
236
|
+
mediaCompleted: boolean;
|
|
237
|
+
visitedCueIdentifiers: string[];
|
|
238
|
+
completedCueIdentifiers: string[];
|
|
239
|
+
activeCueIdentifier?: string;
|
|
240
|
+
aggregateComplete?: boolean;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Delivery state a gate condition reads, supplied by the caller so this module
|
|
244
|
+
* stays pure and depends on neither the controller nor the formative module's
|
|
245
|
+
* live state.
|
|
246
|
+
*/
|
|
247
|
+
export interface TimedMediaDeliveryState {
|
|
248
|
+
/** Item completion by canonical id: the `"responded"` condition's signal. */
|
|
249
|
+
respondedByItemId: Record<string, boolean>;
|
|
250
|
+
/** Last Try correctness by canonical id; absent means no Try yet. */
|
|
251
|
+
correctnessByItemId: Record<string, FormativeCorrectness | undefined>;
|
|
252
|
+
/** Whether every item in the section reports completion. */
|
|
253
|
+
itemsComplete: boolean;
|
|
254
|
+
}
|
|
255
|
+
export type TimedMediaInput = {
|
|
256
|
+
kind: "time";
|
|
257
|
+
currentTimeSeconds: number;
|
|
258
|
+
} | {
|
|
259
|
+
kind: "seek";
|
|
260
|
+
currentTimeSeconds: number;
|
|
261
|
+
} | {
|
|
262
|
+
kind: "ended";
|
|
263
|
+
currentTimeSeconds?: number;
|
|
264
|
+
}
|
|
265
|
+
/** Delivery state changed under a held gate; re-evaluate releases. */
|
|
266
|
+
| {
|
|
267
|
+
kind: "delivery-changed";
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* What the caller must do to the port after a reduction. Advisory when the port
|
|
271
|
+
* lacks the capability, which the caller reports rather than silently skipping.
|
|
272
|
+
*/
|
|
273
|
+
export interface TimedMediaEffects {
|
|
274
|
+
/** A gate is holding and playback should stop. */
|
|
275
|
+
pause: boolean;
|
|
276
|
+
/** A forward seek exceeded policy and should be clamped here. */
|
|
277
|
+
seekToSeconds?: number;
|
|
278
|
+
/** Cue that activated in this reduction, for focus and announcement. */
|
|
279
|
+
activatedCueIdentifier?: string;
|
|
280
|
+
/** Cue whose gate released in this reduction, for announcement. */
|
|
281
|
+
releasedCueIdentifier?: string;
|
|
282
|
+
}
|
|
283
|
+
export interface TimedMediaReduction {
|
|
284
|
+
state: TimedMediaSectionSessionSlice;
|
|
285
|
+
effects: TimedMediaEffects;
|
|
286
|
+
/** `false` when the reduction produced the state it was given. */
|
|
287
|
+
changed: boolean;
|
|
288
|
+
}
|
|
289
|
+
export type TimedMediaEnforcement = "enforced" | "advisory";
|
|
290
|
+
export interface TimedMediaDegradation {
|
|
291
|
+
policy: "pause-on-required-cue" | "restrict-seek-ahead";
|
|
292
|
+
/** The capability the port does not have. */
|
|
293
|
+
capability: keyof MediaTimeSourceCapabilities;
|
|
294
|
+
message: string;
|
|
295
|
+
}
|
|
296
|
+
export interface TimedMediaGateView {
|
|
297
|
+
cueIdentifier: string;
|
|
298
|
+
itemRefs: string[];
|
|
299
|
+
releaseOn: TimedMediaGateCondition;
|
|
300
|
+
/** The gate is active and its condition does not hold. */
|
|
301
|
+
holding: boolean;
|
|
302
|
+
/** Whether holding actually stops playback, or is merely recorded. */
|
|
303
|
+
enforcement: TimedMediaEnforcement;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* What layouts read off the composition model. Derived, never persisted — the
|
|
307
|
+
* slice above is the state, this is the view of it.
|
|
308
|
+
*/
|
|
309
|
+
export interface TimedMediaSectionProjection {
|
|
310
|
+
version: 1;
|
|
311
|
+
stimulusRenderableId: string;
|
|
312
|
+
cues: ResolvedTimedMediaCue[];
|
|
313
|
+
playbackPolicy: TimedMediaPlaybackPolicy;
|
|
314
|
+
/** Whether a media time source is attached; cues cannot fire without one. */
|
|
315
|
+
mediaAttached: boolean;
|
|
316
|
+
enforcement: {
|
|
317
|
+
pause: TimedMediaEnforcement;
|
|
318
|
+
seek: TimedMediaEnforcement;
|
|
319
|
+
};
|
|
320
|
+
degradations: TimedMediaDegradation[];
|
|
321
|
+
/** Canonical ids of items whose cue has activated. Monotonic. */
|
|
322
|
+
revealedItemIds: string[];
|
|
323
|
+
/**
|
|
324
|
+
* Canonical ids of every item the timeline sequences — named by a `reveal` or a
|
|
325
|
+
* `gate` cue. An item outside this set is delivered normally, including one a
|
|
326
|
+
* `metadata` cue names: metadata records state and reveals nothing, so naming an
|
|
327
|
+
* item on one must not take that item out of delivery.
|
|
328
|
+
*/
|
|
329
|
+
sequencedItemIds: string[];
|
|
330
|
+
activeCueIdentifier?: string;
|
|
331
|
+
gate: TimedMediaGateView | null;
|
|
332
|
+
visitedCueIdentifiers: string[];
|
|
333
|
+
completedCueIdentifiers: string[];
|
|
334
|
+
mediaCurrentTime: number;
|
|
335
|
+
maxPositionSeconds: number;
|
|
336
|
+
mediaCompleted: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Every required cue complete, every item complete, and — where the policy
|
|
339
|
+
* requires it — the media ended. Deliberately three separate facts rather than
|
|
340
|
+
* one: a section can be item-complete with the video half watched.
|
|
341
|
+
*/
|
|
342
|
+
aggregateComplete: boolean;
|
|
343
|
+
}
|