@intellectif/lk-react 6.1.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +2 -0
  3. package/dist/{WrittenResponse-BXHidIaK.d.cts → WrittenResponse-BJL4dGsR.d.ts} +5 -1
  4. package/dist/{WrittenResponse-DJYGuMKk.d.ts → WrittenResponse-zWT-Go1g.d.cts} +5 -1
  5. package/dist/chunk-6ZKVJAO6.js +540 -0
  6. package/dist/chunk-6ZKVJAO6.js.map +1 -0
  7. package/dist/chunk-A572N5G7.cjs +542 -0
  8. package/dist/chunk-A572N5G7.cjs.map +1 -0
  9. package/dist/{chunk-UQ3BBEIT.js → chunk-BG456EM3.js} +21 -5
  10. package/dist/chunk-BG456EM3.js.map +1 -0
  11. package/dist/{chunk-BE7R2X3S.cjs → chunk-BNESCXJ5.cjs} +16 -4
  12. package/dist/chunk-BNESCXJ5.cjs.map +1 -0
  13. package/dist/{chunk-MDZMKC27.js → chunk-CMZGT26S.js} +21 -4
  14. package/dist/chunk-CMZGT26S.js.map +1 -0
  15. package/dist/{chunk-57E3E2H4.js → chunk-J23XVR2B.js} +16 -4
  16. package/dist/chunk-J23XVR2B.js.map +1 -0
  17. package/dist/{chunk-DUIWK272.cjs → chunk-J4LP4CLY.cjs} +109 -29
  18. package/dist/chunk-J4LP4CLY.cjs.map +1 -0
  19. package/dist/{chunk-NEWGUDA5.cjs → chunk-KK4WGW2V.cjs} +21 -5
  20. package/dist/chunk-KK4WGW2V.cjs.map +1 -0
  21. package/dist/{chunk-MDYKYDRN.cjs → chunk-KPI335JJ.cjs} +21 -4
  22. package/dist/chunk-KPI335JJ.cjs.map +1 -0
  23. package/dist/{chunk-ZLUMUJUQ.js → chunk-MB532SXM.js} +16 -4
  24. package/dist/chunk-MB532SXM.js.map +1 -0
  25. package/dist/{chunk-NPY2F7F6.cjs → chunk-VXOOGQ6W.cjs} +16 -4
  26. package/dist/chunk-VXOOGQ6W.cjs.map +1 -0
  27. package/dist/{chunk-IUODGQXE.js → chunk-ZPVMB3LH.js} +107 -27
  28. package/dist/chunk-ZPVMB3LH.js.map +1 -0
  29. package/dist/components/ActivitySequence.cjs +7 -7
  30. package/dist/components/ActivitySequence.d.cts +13 -15
  31. package/dist/components/ActivitySequence.d.ts +13 -15
  32. package/dist/components/ActivitySequence.js +6 -6
  33. package/dist/components/FillInTheBlanks.cjs +3 -3
  34. package/dist/components/FillInTheBlanks.d.cts +1 -1
  35. package/dist/components/FillInTheBlanks.d.ts +1 -1
  36. package/dist/components/FillInTheBlanks.js +2 -2
  37. package/dist/components/MultipleChoice.cjs +3 -3
  38. package/dist/components/MultipleChoice.d.cts +1 -1
  39. package/dist/components/MultipleChoice.d.ts +1 -1
  40. package/dist/components/MultipleChoice.js +2 -2
  41. package/dist/components/StimulusPanel.cjs +3 -3
  42. package/dist/components/StimulusPanel.d.cts +19 -3
  43. package/dist/components/StimulusPanel.d.ts +19 -3
  44. package/dist/components/StimulusPanel.js +2 -2
  45. package/dist/components/WrittenResponse.cjs +3 -3
  46. package/dist/components/WrittenResponse.d.cts +3 -3
  47. package/dist/components/WrittenResponse.d.ts +3 -3
  48. package/dist/components/WrittenResponse.js +2 -2
  49. package/dist/index.cjs +11 -11
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.d.cts +2 -2
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +6 -6
  54. package/dist/index.js.map +1 -1
  55. package/dist/theme/skin.css +204 -1
  56. package/dist/{types-9iS1Vs2I.d.ts → types-eXX4hzSj.d.cts} +128 -2
  57. package/dist/{types-9iS1Vs2I.d.cts → types-eXX4hzSj.d.ts} +128 -2
  58. package/package.json +3 -3
  59. package/dist/chunk-4HZ7LQIH.cjs +0 -41
  60. package/dist/chunk-4HZ7LQIH.cjs.map +0 -1
  61. package/dist/chunk-57E3E2H4.js.map +0 -1
  62. package/dist/chunk-BE7R2X3S.cjs.map +0 -1
  63. package/dist/chunk-DUIWK272.cjs.map +0 -1
  64. package/dist/chunk-GB5URWM4.js +0 -39
  65. package/dist/chunk-GB5URWM4.js.map +0 -1
  66. package/dist/chunk-IUODGQXE.js.map +0 -1
  67. package/dist/chunk-MDYKYDRN.cjs.map +0 -1
  68. package/dist/chunk-MDZMKC27.js.map +0 -1
  69. package/dist/chunk-NEWGUDA5.cjs.map +0 -1
  70. package/dist/chunk-NPY2F7F6.cjs.map +0 -1
  71. package/dist/chunk-UQ3BBEIT.js.map +0 -1
  72. package/dist/chunk-ZLUMUJUQ.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # @intellectif/lk-react
2
2
 
3
+ ## 7.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 011e0d1: Media playback policy — a listening paper can now be played on the paper's terms, and two exam-integrity guards that were watching the wrong doors are closed.
8
+
9
+ Listening assessment was the last thing in v0.4 that could not be run through the SDK at all. An integrating application had replaced the SDK's audio rendering wholesale with a bespoke 56-line player, and the reason was capability, not styling: `<audio controls>` offers a scrubber, a speed control and a download, and a listening paper needs none of them.
10
+
11
+ **`media.playback`, on audio.** `{ maxPlays, seek, rate, controls, nativeControlHints }`, every field optional and every default reproducing the pre-0.8.0 behaviour byte for byte. `{ maxPlays: 2 }` alone is a complete policy: `controls` resolves to `minimal` and `seek` to `none`, because those are the only values that can keep the promise. The browser's own bar leaves its play button enabled after a budget is spent and keeps a scrubber that would move and silently snap back — a control that looks operable and does nothing (WCAG 3.2.2 / 4.1.3). So an enforcing policy replaces the bar rather than lying with it, and the schema **refuses** the combinations that would: `controls: 'native'` with a budget, `maxPlays` with `seek: 'allow'` (scrubbing back would replay the whole recording for free), a bare `minimal` with nothing to enforce, and hints that an enforcing policy would render inert. `playback` on `video`, `image` or `embed` is rejected outright, with the reason: a provider iframe needs the provider's own JS API, and a video transport would have to own fullscreen and Picture-in-Picture, which this release does not.
12
+
13
+ The policy object is **strict** while `media` stays loose. A typo one level down (`maxPlay`, `seeking`) would otherwise be accepted in silence and disarm the whole control on an exam that believed it was enforced.
14
+
15
+ **What replaces the browser's bar is a superset, minus what the policy removes on purpose.** Play/pause, elapsed and total time, mute, volume, an optional speed control, an optional scrubber, and a live plays-remaining status. Volume and mute are never restricted by any policy — they affect no assessment property, and a learner in a lab with a locked OS volume has no other lever once the native bar is gone. An exhausted play button uses `aria-disabled`, never `disabled`: a natively disabled button leaves the focus order, so a learner who tabs into nothing is told nothing, and disabling it mid-attempt would drop focus to `<body>`. Pressing it re-announces the refusal. Every string is overridable through `mediaStrings`, because "No plays remaining" is the highest-stakes text on a listening paper and shipping it as untranslatable English inside a Spanish panel was not acceptable. `review` mode never enforces: a graded paper cannot be changed by listening again, and taking away a learner's scrubber and speed control while they work out what they got wrong helps nobody.
16
+
17
+ **Honest about what a browser actually gives.** `maxPlays`, `seek: 'none'` and `rate: 'fixed'` are _enforced_: a refused play is stopped inside the browser's own `play` event, verified in a real engine at `currentTime < 0.25s`, and the budget lives on the element's event rather than on the button so a hardware media key or a scripted `play()` goes through it too. `nativeControlHints` is _advisory_: it emits `controlsList`, which some engines ignore entirely, and `hide-download` **never prevents a download** — the URL is in the page and the bytes are in the network panel. Nothing here survives devtools. Those are not adjectives; `packages/lk-react/e2e/media-capability-probe.spec.ts` exercises each mechanism in a real browser and runs in CI, so no documented claim outlives the behaviour it rests on.
18
+
19
+ **A budget is only as durable as your write.** The SDK reads and writes no store, so `maxPlays` means nothing across a refresh unless the consumer persists it. `mediaBudget.onPlayConsumed` is a payment, not telemetry: return a promise and playback is held until an atomic server write settles — the only tier in which "consumed before audible" is true of storage rather than only of memory, and the only construction that catches a second tab, since two mounts seeded at 0 both claim 1. Return nothing and playback starts immediately, which is fine for practice — but do not debounce it and do not batch it with the answer autosave; an eight-second debounce is exactly long enough to start a third play and hard-reload. In `exam` mode, budgeted media without `onPlayConsumed` throws at render, and so does a resumed attempt that omits the ledger: a paper granting unlimited plays while showing "2 plays remaining" is indistinguishable, to the learner and to an appeal, from one that works.
20
+
21
+ `MediaPlayLedger` is a **separate** persisted object, not a field on `AttemptState`. Two reasons, both of which bite in production: it is written on a different cadence, and `restoreAttemptState` rebuilds its output field by field — so a build predating this release would not merely fail to read the counts, it would write the snapshot back without them and erase spent plays across a staged rollout, at exactly the moment they matter. `serializeMediaPlayLedger` / `restoreMediaPlayLedger` bind it to its paper by `planHash` for the same reason `AttemptState` is bound.
22
+
23
+ Budgets are frozen into the plan by `planAttempt`, so editing `maxPlays: 2 → 4` mid-window cannot change what a past learner was held to. `planAttempt` also refuses a paper where one recording is budgeted under several keys: six questions each carrying the same clip at `maxPlays: 2` is twelve plays of one recording. Questions that share a recording belong in an item group — one stimulus, one budget, keyed by the entry so a six-question group gets one budget rather than six.
24
+
25
+ **`redact()` had a fail-open hole at exactly this nesting level, and it is fixed first.** `media` was classified as a single `public` leaf, so the whole object was forwarded without recursing — and an unclassified key nested under it survived `redact()` **and passed `assertRedacted()`**. A hand-planted `media.secretAnswerHint` reached the learner on 0.7.1. The documented fail-closed contract ("a field the policy does not classify is removed") stopped at the media boundary; it no longer does. Unknown keys under `media` are now dropped and rejected, `RedactedMediaSchema` is strict like the shapes that embed it, and `redact()` returns a copy rather than an alias. Verified against the published 0.7.1 build: every existing paper plans to a byte-identical `planHash` and redacts to identical output.
26
+
27
+ **Two exam-integrity guards that were watching the wrong doors:**
28
+
29
+ - **An unseeded item-level shuffle rendered happily in `exam` mode.** `ActivitySequence` demanded a `shuffleSeed` for `shuffle="entries"` and for a group's `shuffle: 'within-group'`, but never for an activity's own `data.shuffle` — and `<MultipleChoice>` invents a per-mount seed when none reaches it, in every render mode. A single item with `shuffle: true` produced four different option orders across four mounts, without complaint: an arrangement the server cannot rebuild, which is precisely what the guard exists to refuse, arriving through the one door it did not watch.
30
+ - **`<WrittenResponse>` had no redacted-in-`practice` guard.** Its two siblings have refused redacted data there since 0.5.0. The asymmetry looked harmless because this component never scores locally — but `practice` still runs the local submit path and emits a practice-mode xAPI statement for work the server is meant to grade, so an all-essay redacted paper mounted without `renderMode` rendered, stayed answerable, and reported success at every step. The one item type whose grading is entirely someone else's job was the one that failed silently.
31
+
32
+ Both now throw at render. That is the breaking half of this release for anyone who was relying on the old behaviour — though what they were relying on was an exam whose order could not be reproduced, or an essay graded nowhere.
33
+
34
+ **On `maxPlays` specifically, and stated plainly because this is a public changelog:** it is a roadmap hypothesis, not a replicated production behaviour. A search of the one integrating application for any play-count identifier, UI string or database column returned nothing. What it had actually built was seek, download and rate suppression — which is why those are the parts with a real usage story behind them, and why `maxPlays` ships with its persistence requirements spelled out rather than assumed.
35
+
36
+ **A blank left empty could score as correct.** Levenshtein distance from an empty string is just the answer's length, so `levenshtein: 1` on a one-letter blank — an article, or "I" — accepted an **unanswered** blank: `matchText('', ['a'], { levenshtein: 1 })` returned `{ matched: true, via: 'fuzzy' }`. On a partial-credit gap-fill that silently awarded a mark for work the learner never did, and no test covered the path. An empty or whitespace-only input no longer reaches the fuzzy stage at all. Exact and normalized matching are untouched, so an author who deliberately lists `""` as an accepted answer still gets it, one stage earlier.
37
+
38
+ **This can change a recorded grade** — for exactly one population: attempts where an unanswered blank was marked correct by fuzzy tolerance. Every one of those marks was wrong. If you have stored gap-fill grades under a `levenshtein` policy with very short accepted answers, they are worth recomputing. Related hazard, unchanged and now documented rather than silently fixed: `levenshtein: 1` against a one-character answer still accepts _any_ single character, because that is literally what an edit distance of 1 means. Prefer exact matching on single-letter blanks.
39
+
40
+ **Three skin bugs in the post-submit marking**, all reported by a consumer against 6.1.0 and all reproduced by computed style in a real engine before being fixed:
41
+
42
+ - **A correctly chosen option kept the selection colour.** Specificity, not order: `.lk-mc-option:has(input:checked)` is (0,2,1) and outranked `.lk-mc-option[data-correct="true"]` at (0,2,0), so the single most reassuring state in the component — "you picked this and it is right" — was the only one that did not mark. The wrong-and-chosen case worked only because someone had written the combined selector for it; this adds its missing counterpart.
43
+ - **Hovering a marked option repainted it in the selection colour.** `.lk-mc-option:hover` was unguarded, and for an unchosen distractor no marking rule matches at all — so after submit, hovering a disabled, already-marked option showed the "still selectable" affordance. Hover is now scoped to `:not([data-correct])`, which is precisely "not yet marked".
44
+ - **Option text wrapped under a lonely radio on narrow screens.** The option is a flex row and consumers commonly add `flex-wrap: wrap` so a per-option note can drop below it; in a wrapping container an item wraps _before_ it shrinks, and the text span had no flex contract. It now has one, with a minimum inline size so a wide sibling wraps instead of crushing the text into a one-character-per-line column.
45
+
46
+ `e2e/skin-marking.spec.ts` pins all three by **computed border colour and box geometry** in a real browser. Unit tests assert class names and `data-*` attributes and can never see which rule wins, which is why this class of bug survived several releases.
47
+
48
+ Fixing the first of those exposed a design gap the fix alone would have left in place: once a correctly chosen option marked green, it became indistinguishable from the correct answer a learner **missed**, which also marks green. The skin now separates them by weight rather than hue — chosen-and-right is solid with a tint and a `✓`, chosen-and-wrong is solid red with a `✗`, and the missed correct answer is a **dashed** green outline with a hollow `○` and no fill. Untouched distractors recede to 70% opacity, a figure chosen by computing the contrast (6.6:1; the first draft's 55% would have failed AA at 3.9:1). The glyphs also close a WCAG 1.4.1 gap: the component emits no correctness text, so a mark was previously carried by colour alone. They are decorative generated content on purpose — a translatable announcement belongs to the component and is tracked with the i18n work.
49
+
50
+ Also in this release: `slotKey` containing a `.` is now rejected at `validateItemGroup` rather than at `flattenSequence`, so an author finds it instead of a learner; `InteractionKind` gains `media-play-consumed` / `-refused` / `-errored` / `-refunded`, emitted only when a budget is in force so a `review` replay cannot pollute the record an appeal reads; and the pager's stimulus pane now keys on the set as well as the entry, so a set change to a different paper that reuses an entry key remounts the panel instead of leaking a mount-only seed into the next paper.
51
+
52
+ `@intellectif/lk-react` goes major because `@intellectif/lk-core` is a peer dependency and a core minor raises the floor, and because of the two new throws. Every new prop is optional.
53
+
54
+ ### Patch Changes
55
+
56
+ - Updated dependencies [011e0d1]
57
+ - @intellectif/lk-core@0.8.0
58
+
3
59
  ## 6.1.0
4
60
 
5
61
  ### Minor Changes
package/README.md CHANGED
@@ -78,6 +78,7 @@ export function Demo() {
78
78
  - **Custom activity types** — `renderers={{ 'my-type': MyRenderer }}` on the sequence; a key matching a built-in overrides it, so you can replace a bundled renderer without forking the sequencer.
79
79
  - **Rich text, opt-in** — `sanitizeHtml` renders author-supplied `questionHtml` / `promptHtml`. The SDK ships **no** sanitiser and injects no HTML without one; without it the escaped plain-text field is used. (`FillInTheBlanks` ignores `passageHtml` by design — its passage hosts the answer inputs.)
80
80
  - **Media per question** — optional `image` / `audio` / `video` / `embed` (YouTube/Vimeo iframe) above the question; alt-text required for `image`/`embed` (WCAG).
81
+ - **Playback policy for listening papers** — an audio recording can declare `maxPlays`, `seek: 'none'` and `rate: 'fixed'`. The SDK then renders its own accessible transport (44px targets, a live plays-remaining status, an exhausted button that stays focusable and says why) and enforces the policy from the element's own events, so a hardware media key goes through the budget too. Wire `mediaBudget` on `<ActivitySequence>` to make a play budget survive a refresh.
81
82
  - **Activity-level overall feedback** — `{ correct, incorrect }` shown after submit (h5p "Overall Feedback" parity).
82
83
  - **`useXAPI(config)`** — fire-and-forget LRS delivery with retry/backoff for 5xx/network (1 s / 2 s / 4 s), immediate fail on 4xx, never throws.
83
84
  - **`useActivityState()`** — `idle → in-progress → completed → reviewing` machine with `getTimeSpent()`.
@@ -121,6 +122,7 @@ subpath of their own.
121
122
  | **Rich text** | Rendered only when you pass `sanitizeHtml`; the SDK bundles no sanitiser and injects no HTML without one. `FillInTheBlanks` deliberately **ignores** `passageHtml` — the passage hosts the answer inputs, so it is built from `passage` plus the blanks (dev-mode warning if you pass it). |
122
123
  | **i18n** | `locale` sets the `lang` attribute on the rendered region and names xAPI statements. UI strings are English and not yet overridable, and there is no RTL-specific styling. |
123
124
  | **Media** | `<audio>` / `<video>` are paused when the pager navigates away, preserving `currentTime` so a group resumes where the learner left it; nothing ever auto-plays. A provider `embed` (iframe) **cannot** be paused this way — controlling a third-party player needs its own JS API. Use `audio` / `video` for anything that must stop when the learner navigates. |
125
+ | **Playback policy** | Audio only. `maxPlays` / `seek` / `rate` are enforced by the SDK's own transport — a refused play is stopped inside the browser's `play` event, before a sample is audible. `nativeControlHints` is **advisory**: it emits `controlsList`, which some engines ignore, and never prevents a download. A budget is durable only if you persist it through `mediaBudget.onPlayConsumed`; the SDK stores nothing. Nothing here survives devtools. Not implemented for `video` or `embed`. |
124
126
  | **Authoring / content storage / CDN / auth** | Consumer responsibility — typed schemas + `validateActivity` + JSON Schema export are provided for you to build authoring on. |
125
127
  | **SSR / RSC** | Fully supported; every component carries `'use client'`. |
126
128
 
@@ -1,5 +1,5 @@
1
1
  import { XAPIStatement, WrittenResponseData, LearnerResponse, ItemOutcome, InteractionEvent, ThemeTokens } from '@intellectif/lk-core';
2
- import { a as Renderable, R as RenderMode, H as HtmlSanitizer } from './types-9iS1Vs2I.cjs';
2
+ import { b as Renderable, R as RenderMode, H as HtmlSanitizer, M as MediaBudgetBinding, a as MediaTransportStrings } from './types-eXX4hzSj.js';
3
3
 
4
4
  /**
5
5
  * Payload delivered when the learner submits a written response. Grading is
@@ -83,6 +83,10 @@ interface WrittenResponseProps {
83
83
  outcome?: ItemOutcome;
84
84
  /** Renders `data.promptHtml` when provided. See `HtmlSanitizer`. */
85
85
  sanitizeHtml?: HtmlSanitizer;
86
+ /** Binds this activity's own `data.media` to a play budget. See {@link MediaBudgetBinding}. */
87
+ mediaBudget?: MediaBudgetBinding;
88
+ /** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
89
+ mediaStrings?: Partial<MediaTransportStrings>;
86
90
  onInteraction?: (event: InteractionEvent) => void;
87
91
  /** Per-instance token overrides, applied as inline CSS vars on the root. */
88
92
  theme?: Partial<ThemeTokens>;
@@ -1,5 +1,5 @@
1
1
  import { XAPIStatement, WrittenResponseData, LearnerResponse, ItemOutcome, InteractionEvent, ThemeTokens } from '@intellectif/lk-core';
2
- import { a as Renderable, R as RenderMode, H as HtmlSanitizer } from './types-9iS1Vs2I.js';
2
+ import { b as Renderable, R as RenderMode, H as HtmlSanitizer, M as MediaBudgetBinding, a as MediaTransportStrings } from './types-eXX4hzSj.cjs';
3
3
 
4
4
  /**
5
5
  * Payload delivered when the learner submits a written response. Grading is
@@ -83,6 +83,10 @@ interface WrittenResponseProps {
83
83
  outcome?: ItemOutcome;
84
84
  /** Renders `data.promptHtml` when provided. See `HtmlSanitizer`. */
85
85
  sanitizeHtml?: HtmlSanitizer;
86
+ /** Binds this activity's own `data.media` to a play budget. See {@link MediaBudgetBinding}. */
87
+ mediaBudget?: MediaBudgetBinding;
88
+ /** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
89
+ mediaStrings?: Partial<MediaTransportStrings>;
86
90
  onInteraction?: (event: InteractionEvent) => void;
87
91
  /** Per-instance token overrides, applied as inline CSS vars on the root. */
88
92
  theme?: Partial<ThemeTokens>;
@@ -0,0 +1,540 @@
1
+ 'use client';
2
+ import { resolvePlaybackPolicy } from '@intellectif/lk-core';
3
+ import { useRef, useId, useState, useCallback, useEffect } from 'react';
4
+ import { jsx, jsxs } from 'react/jsx-runtime';
5
+
6
+ // src/components/shared/ActivityMedia.tsx
7
+ var DEFAULT_STRINGS = {
8
+ play: "Play",
9
+ pause: "Pause",
10
+ preparing: "Preparing\u2026",
11
+ mute: "Mute",
12
+ unmute: "Unmute",
13
+ volume: "Volume",
14
+ speed: "Playback speed",
15
+ seek: "Seek",
16
+ playsRemaining: (remaining, max) => `${remaining} of ${max} play${max === 1 ? "" : "s"} remaining`,
17
+ noPlaysRemaining: "No plays remaining",
18
+ lastPlayConfirm: "This is your last play. Start it now?",
19
+ lastPlayStart: "Start last play",
20
+ lastPlayCancel: "Not yet",
21
+ seekBlocked: "Rewinding is not available for this recording.",
22
+ rateBlocked: "Playback speed is fixed for this recording.",
23
+ playFailed: "The recording could not be started. Try again."
24
+ };
25
+ var RESUME_EPSILON = 0.25;
26
+ var SEEK_EPSILON = 0.35;
27
+ var NO_PROGRESS = 0.25;
28
+ var formatTime = (seconds) => {
29
+ if (!Number.isFinite(seconds) || seconds < 0) {
30
+ return "0:00";
31
+ }
32
+ const whole = Math.floor(seconds);
33
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
34
+ };
35
+ function AudioTransport({
36
+ media,
37
+ policy,
38
+ renderMode = "practice",
39
+ disabled = false,
40
+ locale,
41
+ mediaBudget,
42
+ mediaStrings,
43
+ onInteraction
44
+ }) {
45
+ const elementRef = useRef(null);
46
+ const statusId = useId();
47
+ const suppliedStrings = mediaBudget?.strings ?? mediaStrings;
48
+ const s = { ...DEFAULT_STRINGS, ...mediaStrings, ...mediaBudget?.strings };
49
+ const enforced = mediaBudget?.enforced ?? renderMode !== "review";
50
+ const budgeted = enforced && policy.maxPlays !== null && mediaBudget !== void 0;
51
+ const maxPlays = policy.maxPlays ?? 0;
52
+ const usedRef = useRef(mediaBudget?.entry?.plays ?? 0);
53
+ const pendingRef = useRef(false);
54
+ const resumeAtRef = useRef(null);
55
+ const highWaterRef = useRef(0);
56
+ const chargeRef = useRef(null);
57
+ const seededRef = useRef(false);
58
+ const lastPositionRef = useRef(-1);
59
+ const selfStartRef = useRef(false);
60
+ const [used, setUsed] = useState(usedRef.current);
61
+ const [playing, setPlaying] = useState(false);
62
+ const [pending, setPending] = useState(false);
63
+ const [elapsed, setElapsed] = useState(0);
64
+ const [duration, setDuration] = useState(0);
65
+ const [muted, setMuted] = useState(false);
66
+ const [volume, setVolume] = useState(1);
67
+ const [confirming, setConfirming] = useState(false);
68
+ const [notice, setNotice] = useState("");
69
+ const [noticeNonce, setNoticeNonce] = useState(0);
70
+ const remaining = Math.max(0, maxPlays - used);
71
+ const announce = useCallback((message) => {
72
+ setNotice(message);
73
+ setNoticeNonce((n) => n + 1);
74
+ }, []);
75
+ const emit = useCallback(
76
+ (type, playsUsed) => {
77
+ if (!budgeted || mediaBudget === void 0 || onInteraction === void 0) {
78
+ return;
79
+ }
80
+ onInteraction({
81
+ type,
82
+ activityId: mediaBudget.activityId ?? mediaBudget.slotId,
83
+ timestamp: Date.now(),
84
+ payload: {
85
+ mediaKey: mediaBudget.key,
86
+ mediaType: "audio",
87
+ playsUsed,
88
+ maxPlays,
89
+ playsRemaining: Math.max(0, maxPlays - playsUsed)
90
+ }
91
+ });
92
+ },
93
+ [budgeted, mediaBudget, maxPlays, onInteraction]
94
+ );
95
+ const reportPosition = useCallback(
96
+ (seconds) => {
97
+ mediaBudget?.onPosition?.(mediaBudget.key, seconds);
98
+ },
99
+ [mediaBudget]
100
+ );
101
+ const isResume = useCallback((element) => {
102
+ const at = resumeAtRef.current;
103
+ return at !== null && Math.abs(element.currentTime - at) < RESUME_EPSILON;
104
+ }, []);
105
+ const buildClaim = useCallback(() => {
106
+ if (mediaBudget === void 0) {
107
+ return null;
108
+ }
109
+ return {
110
+ key: mediaBudget.key,
111
+ previousPlaysUsed: usedRef.current,
112
+ playsUsed: usedRef.current + 1,
113
+ maxPlays,
114
+ playsRemaining: Math.max(0, maxPlays - (usedRef.current + 1)),
115
+ slotId: mediaBudget.slotId,
116
+ index: mediaBudget.index,
117
+ ...mediaBudget.activityId !== void 0 ? { activityId: mediaBudget.activityId } : {}
118
+ };
119
+ }, [mediaBudget, maxPlays]);
120
+ const refuse = useCallback(() => {
121
+ announce(s.noPlaysRemaining);
122
+ emit("media-play-refused", usedRef.current);
123
+ }, [announce, emit, s.noPlaysRemaining]);
124
+ const startPlayback = useCallback(() => {
125
+ const element = elementRef.current;
126
+ if (element === null) {
127
+ return;
128
+ }
129
+ highWaterRef.current = element.currentTime;
130
+ selfStartRef.current = true;
131
+ void element.play().catch(() => {
132
+ selfStartRef.current = false;
133
+ });
134
+ }, []);
135
+ const commit = useCallback(
136
+ (claim) => {
137
+ const element = elementRef.current;
138
+ chargeRef.current = { claim, at: element?.currentTime ?? 0 };
139
+ usedRef.current = claim.playsUsed;
140
+ setUsed(claim.playsUsed);
141
+ emit("media-play-consumed", claim.playsUsed);
142
+ },
143
+ [emit]
144
+ );
145
+ const claimAndStart = useCallback(
146
+ (element, alreadyPlaying) => {
147
+ const claim = buildClaim();
148
+ if (claim === null) {
149
+ return;
150
+ }
151
+ const result = mediaBudget?.onPlayConsumed?.(claim);
152
+ if (result !== void 0 && typeof result.then === "function") {
153
+ if (alreadyPlaying) {
154
+ element.pause();
155
+ }
156
+ pendingRef.current = true;
157
+ setPending(true);
158
+ void result.then((grant) => {
159
+ const granted = grant !== void 0 && Number.isInteger(grant.playsUsed) ? grant.playsUsed : claim.playsUsed;
160
+ if (granted > maxPlays) {
161
+ usedRef.current = granted;
162
+ setUsed(granted);
163
+ refuse();
164
+ return;
165
+ }
166
+ commit({ ...claim, playsUsed: granted });
167
+ startPlayback();
168
+ }).catch(() => {
169
+ announce(s.playFailed);
170
+ emit("media-play-errored", usedRef.current);
171
+ }).finally(() => {
172
+ pendingRef.current = false;
173
+ setPending(false);
174
+ });
175
+ return;
176
+ }
177
+ commit(claim);
178
+ if (!alreadyPlaying) {
179
+ startPlayback();
180
+ }
181
+ },
182
+ [
183
+ buildClaim,
184
+ mediaBudget,
185
+ maxPlays,
186
+ commit,
187
+ startPlayback,
188
+ refuse,
189
+ announce,
190
+ emit,
191
+ s.playFailed
192
+ ]
193
+ );
194
+ const handleButton = useCallback(() => {
195
+ const element = elementRef.current;
196
+ if (element === null || pendingRef.current || disabled) {
197
+ return;
198
+ }
199
+ if (!element.paused) {
200
+ element.pause();
201
+ return;
202
+ }
203
+ if (!budgeted || isResume(element)) {
204
+ startPlayback();
205
+ return;
206
+ }
207
+ if (remaining === 0) {
208
+ refuse();
209
+ return;
210
+ }
211
+ if (remaining === 1 && !confirming) {
212
+ setConfirming(true);
213
+ return;
214
+ }
215
+ setConfirming(false);
216
+ claimAndStart(element, false);
217
+ }, [disabled, budgeted, isResume, startPlayback, remaining, refuse, confirming, claimAndStart]);
218
+ const handleLoadedMetadata = useCallback(() => {
219
+ const element = elementRef.current;
220
+ if (element === null) {
221
+ return;
222
+ }
223
+ setDuration(Number.isFinite(element.duration) ? element.duration : 0);
224
+ const at = mediaBudget?.entry?.at;
225
+ if (!seededRef.current && typeof at === "number" && Number.isFinite(at) && at > 0) {
226
+ seededRef.current = true;
227
+ highWaterRef.current = at;
228
+ resumeAtRef.current = at;
229
+ element.currentTime = at;
230
+ setElapsed(at);
231
+ }
232
+ }, [mediaBudget]);
233
+ const handlePlay = useCallback(() => {
234
+ const element = elementRef.current;
235
+ if (element === null) {
236
+ return;
237
+ }
238
+ if (disabled) {
239
+ element.pause();
240
+ return;
241
+ }
242
+ if (selfStartRef.current) {
243
+ selfStartRef.current = false;
244
+ setPlaying(true);
245
+ return;
246
+ }
247
+ if (pendingRef.current) {
248
+ setPlaying(true);
249
+ return;
250
+ }
251
+ if (isResume(element)) {
252
+ setPlaying(true);
253
+ resumeAtRef.current = null;
254
+ return;
255
+ }
256
+ if (!budgeted) {
257
+ setPlaying(true);
258
+ return;
259
+ }
260
+ if (remaining === 0) {
261
+ element.pause();
262
+ refuse();
263
+ return;
264
+ }
265
+ setPlaying(true);
266
+ claimAndStart(element, true);
267
+ }, [disabled, isResume, budgeted, remaining, refuse, claimAndStart]);
268
+ const handlePause = useCallback(() => {
269
+ const element = elementRef.current;
270
+ setPlaying(false);
271
+ if (element === null) {
272
+ return;
273
+ }
274
+ resumeAtRef.current = element.currentTime;
275
+ reportPosition(element.currentTime);
276
+ }, [reportPosition]);
277
+ const handleEnded = useCallback(() => {
278
+ setPlaying(false);
279
+ resumeAtRef.current = null;
280
+ highWaterRef.current = 0;
281
+ chargeRef.current = null;
282
+ setElapsed(0);
283
+ reportPosition(0);
284
+ }, [reportPosition]);
285
+ const handleTimeUpdate = useCallback(() => {
286
+ const element = elementRef.current;
287
+ if (element === null) {
288
+ return;
289
+ }
290
+ setElapsed(element.currentTime);
291
+ highWaterRef.current = Math.max(highWaterRef.current, element.currentTime);
292
+ const whole = Math.floor(element.currentTime);
293
+ if (whole !== lastPositionRef.current) {
294
+ lastPositionRef.current = whole;
295
+ reportPosition(element.currentTime);
296
+ }
297
+ }, [reportPosition]);
298
+ const handleSeeking = useCallback(() => {
299
+ const element = elementRef.current;
300
+ if (element === null || policy.seek !== "none") {
301
+ return;
302
+ }
303
+ if (Math.abs(element.currentTime - highWaterRef.current) > SEEK_EPSILON) {
304
+ element.currentTime = highWaterRef.current;
305
+ announce(s.seekBlocked);
306
+ }
307
+ }, [policy.seek, announce, s.seekBlocked]);
308
+ const handleRateChange = useCallback(() => {
309
+ const element = elementRef.current;
310
+ if (element === null || policy.rate !== "fixed" || element.playbackRate === 1) {
311
+ return;
312
+ }
313
+ element.playbackRate = 1;
314
+ announce(s.rateBlocked);
315
+ }, [policy.rate, announce, s.rateBlocked]);
316
+ const handleError = useCallback(() => {
317
+ const charge = chargeRef.current;
318
+ const element = elementRef.current;
319
+ const progressed = charge !== null && element !== null && element.currentTime - charge.at >= NO_PROGRESS;
320
+ if (charge !== null && !progressed) {
321
+ if (mediaBudget?.onPlayRefunded !== void 0) {
322
+ usedRef.current = charge.claim.previousPlaysUsed;
323
+ setUsed(charge.claim.previousPlaysUsed);
324
+ mediaBudget.onPlayRefunded(charge.claim);
325
+ emit("media-play-refunded", charge.claim.previousPlaysUsed);
326
+ } else {
327
+ emit("media-play-errored", usedRef.current);
328
+ }
329
+ }
330
+ chargeRef.current = null;
331
+ setPlaying(false);
332
+ announce(s.playFailed);
333
+ }, [mediaBudget, emit, announce, s.playFailed]);
334
+ useEffect(() => {
335
+ const element = elementRef.current;
336
+ if (element !== null) {
337
+ element.volume = volume;
338
+ element.muted = muted;
339
+ }
340
+ }, [volume, muted]);
341
+ const exhausted = budgeted && remaining === 0;
342
+ const buttonLabel = pending ? s.preparing : playing ? s.pause : s.play;
343
+ return /* @__PURE__ */ jsxs("figure", { className: "lk-media", "data-controls": "minimal", children: [
344
+ /* @__PURE__ */ jsxs(
345
+ "audio",
346
+ {
347
+ ref: elementRef,
348
+ className: "lk-media-el",
349
+ preload: "metadata",
350
+ "aria-label": media.alt || void 0,
351
+ onLoadedMetadata: handleLoadedMetadata,
352
+ onPlay: handlePlay,
353
+ onPause: handlePause,
354
+ onEnded: handleEnded,
355
+ onTimeUpdate: handleTimeUpdate,
356
+ onSeeking: handleSeeking,
357
+ onRateChange: handleRateChange,
358
+ onError: handleError,
359
+ children: [
360
+ /* @__PURE__ */ jsx("source", { src: media.url }),
361
+ media.captionsUrl ? /* @__PURE__ */ jsx("track", { kind: "captions", src: media.captionsUrl, default: true }) : null
362
+ ]
363
+ }
364
+ ),
365
+ /* @__PURE__ */ jsxs(
366
+ "div",
367
+ {
368
+ className: "lk-media-transport",
369
+ dir: "ltr",
370
+ ...suppliedStrings !== void 0 && locale !== void 0 ? { lang: locale } : {},
371
+ children: [
372
+ /* @__PURE__ */ jsx(
373
+ "button",
374
+ {
375
+ type: "button",
376
+ className: "lk-media-play",
377
+ onClick: handleButton,
378
+ "aria-disabled": exhausted || disabled || void 0,
379
+ "aria-busy": pending || void 0,
380
+ "aria-label": exhausted ? `${s.play} \u2014 ${s.noPlaysRemaining}` : void 0,
381
+ "aria-describedby": budgeted ? statusId : void 0,
382
+ children: buttonLabel
383
+ }
384
+ ),
385
+ /* @__PURE__ */ jsxs("span", { className: "lk-media-time", children: [
386
+ formatTime(elapsed),
387
+ " / ",
388
+ formatTime(duration)
389
+ ] }),
390
+ policy.seek === "allow" ? /* @__PURE__ */ jsx(
391
+ "input",
392
+ {
393
+ type: "range",
394
+ className: "lk-media-scrub",
395
+ min: 0,
396
+ max: duration || 0,
397
+ step: 0.1,
398
+ value: Math.min(elapsed, duration || 0),
399
+ "aria-label": s.seek,
400
+ "aria-valuetext": `${formatTime(elapsed)} of ${formatTime(duration)}`,
401
+ onChange: (event) => {
402
+ const element = elementRef.current;
403
+ if (element !== null) {
404
+ element.currentTime = Number(event.target.value);
405
+ }
406
+ }
407
+ }
408
+ ) : null,
409
+ /* @__PURE__ */ jsx(
410
+ "button",
411
+ {
412
+ type: "button",
413
+ className: "lk-media-mute",
414
+ "aria-pressed": muted,
415
+ onClick: () => setMuted((m) => !m),
416
+ children: muted ? s.unmute : s.mute
417
+ }
418
+ ),
419
+ /* @__PURE__ */ jsx(
420
+ "input",
421
+ {
422
+ type: "range",
423
+ className: "lk-media-volume",
424
+ min: 0,
425
+ max: 1,
426
+ step: 0.05,
427
+ value: volume,
428
+ "aria-label": s.volume,
429
+ onChange: (event) => setVolume(Number(event.target.value))
430
+ }
431
+ ),
432
+ policy.rate === "allow" ? /* @__PURE__ */ jsx(
433
+ "select",
434
+ {
435
+ className: "lk-media-rate",
436
+ "aria-label": s.speed,
437
+ defaultValue: "1",
438
+ onChange: (event) => {
439
+ const element = elementRef.current;
440
+ if (element !== null) {
441
+ element.playbackRate = Number(event.target.value);
442
+ }
443
+ },
444
+ children: ["0.75", "1", "1.25", "1.5"].map((rate) => /* @__PURE__ */ jsxs("option", { value: rate, children: [
445
+ rate,
446
+ "\xD7"
447
+ ] }, rate))
448
+ }
449
+ ) : null
450
+ ]
451
+ }
452
+ ),
453
+ budgeted ? /* @__PURE__ */ jsx("p", { className: "lk-media-plays", id: statusId, role: "status", children: remaining > 0 ? s.playsRemaining(remaining, maxPlays) : s.noPlaysRemaining }) : null,
454
+ /* @__PURE__ */ jsx("p", { className: "lk-media-notice", role: "alert", children: notice }, noticeNonce),
455
+ confirming ? /* @__PURE__ */ jsxs("fieldset", { className: "lk-media-confirm", children: [
456
+ /* @__PURE__ */ jsx("legend", { children: s.lastPlayConfirm }),
457
+ /* @__PURE__ */ jsx(
458
+ "button",
459
+ {
460
+ type: "button",
461
+ className: "lk-media-confirm-start",
462
+ autoFocus: true,
463
+ onClick: () => {
464
+ const element = elementRef.current;
465
+ setConfirming(false);
466
+ if (element !== null) {
467
+ claimAndStart(element, false);
468
+ }
469
+ },
470
+ children: s.lastPlayStart
471
+ }
472
+ ),
473
+ /* @__PURE__ */ jsx(
474
+ "button",
475
+ {
476
+ type: "button",
477
+ className: "lk-media-confirm-cancel",
478
+ onClick: () => setConfirming(false),
479
+ children: s.lastPlayCancel
480
+ }
481
+ )
482
+ ] }) : null
483
+ ] });
484
+ }
485
+ function ActivityMedia(props) {
486
+ const { media, renderMode = "practice" } = props;
487
+ if (media.type === "image") {
488
+ return /* @__PURE__ */ jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsx("img", { className: "lk-media-el", src: media.url, alt: media.alt ?? "" }) });
489
+ }
490
+ if (media.type === "audio") {
491
+ const policy = resolvePlaybackPolicy(media);
492
+ if (policy.controls === "minimal" && renderMode !== "review") {
493
+ return /* @__PURE__ */ jsx(AudioTransport, { ...props, media, policy });
494
+ }
495
+ if (policy.maxPlays !== null && renderMode === "exam" && props.mediaBudget === void 0) {
496
+ throw new Error(
497
+ `ActivityMedia: audio ${JSON.stringify(media.url)} declares maxPlays and is rendered in renderMode "exam" without a \`mediaBudget\` binding. Nothing would persist the count, so a refresh restores the full budget while the page says plays remain. Render it through <ActivitySequence mediaBudget={\u2026}> or pass \`mediaBudget\` yourself.`
498
+ );
499
+ }
500
+ const hints = renderMode === "review" ? [] : policy.nativeControlHints;
501
+ return /* @__PURE__ */ jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsxs(
502
+ "audio",
503
+ {
504
+ className: "lk-media-el",
505
+ controls: true,
506
+ "aria-label": media.alt || void 0,
507
+ ...hints.length > 0 ? {
508
+ controlsList: hints.map((hint) => hint === "hide-download" ? "nodownload" : "noplaybackrate").join(" ")
509
+ } : {},
510
+ children: [
511
+ /* @__PURE__ */ jsx("source", { src: media.url }),
512
+ media.captionsUrl ? /* @__PURE__ */ jsx("track", { kind: "captions", src: media.captionsUrl, default: true }) : null
513
+ ]
514
+ }
515
+ ) });
516
+ }
517
+ if (media.type === "embed") {
518
+ return /* @__PURE__ */ jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsx("div", { className: "lk-media-embed", children: /* @__PURE__ */ jsx(
519
+ "iframe",
520
+ {
521
+ className: "lk-media-el",
522
+ src: media.url,
523
+ title: media.alt ?? "Embedded media",
524
+ loading: "lazy",
525
+ referrerPolicy: "strict-origin-when-cross-origin",
526
+ sandbox: "allow-scripts allow-same-origin allow-presentation",
527
+ allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
528
+ allowFullScreen: true
529
+ }
530
+ ) }) });
531
+ }
532
+ return /* @__PURE__ */ jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsxs("video", { className: "lk-media-el", controls: true, "aria-label": media.alt || void 0, children: [
533
+ /* @__PURE__ */ jsx("source", { src: media.url }),
534
+ media.captionsUrl ? /* @__PURE__ */ jsx("track", { kind: "captions", src: media.captionsUrl, default: true }) : null
535
+ ] }) });
536
+ }
537
+
538
+ export { ActivityMedia };
539
+ //# sourceMappingURL=chunk-6ZKVJAO6.js.map
540
+ //# sourceMappingURL=chunk-6ZKVJAO6.js.map