@webwriter/quiz 1.1.3 → 2.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 (167) hide show
  1. package/.github/workflows/publish.yml +8 -0
  2. package/LICENSE +6 -6
  3. package/README.md +778 -0
  4. package/assets/icons/grip-horizontal.svg +8 -0
  5. package/assets/icons/grip-vertical.svg +8 -0
  6. package/assets/icons/truefalse.svg +6 -0
  7. package/custom-elements.json +2779 -0
  8. package/custom.d.ts +180 -176
  9. package/dist/api/index.d.ts +27 -0
  10. package/dist/api/index.js +40 -0
  11. package/dist/migrate.js +1434 -0
  12. package/dist/widgets/webwriter-choice-item.js +4036 -3895
  13. package/dist/widgets/webwriter-choice.js +2269 -5821
  14. package/dist/widgets/webwriter-gap-item.js +3987 -0
  15. package/dist/widgets/webwriter-gap.js +2908 -0
  16. package/dist/widgets/webwriter-mark.js +4335 -3489
  17. package/dist/widgets/webwriter-order-item.js +4014 -3306
  18. package/dist/widgets/webwriter-order.js +2944 -4386
  19. package/dist/widgets/webwriter-pairing-item.js +1058 -2421
  20. package/dist/widgets/webwriter-pairing.js +2993 -4383
  21. package/dist/widgets/webwriter-quiz-hint.js +2469 -0
  22. package/dist/widgets/webwriter-quiz.js +4933 -6099
  23. package/dist/widgets/webwriter-speech.js +6077 -5230
  24. package/dist/widgets/webwriter-task-prompt.js +4775 -2165
  25. package/dist/widgets/webwriter-task.js +2364 -8382
  26. package/dist/widgets/webwriter-text.js +4169 -4337
  27. package/dist/widgets/webwriter-true-false.js +4674 -0
  28. package/editing-config.json +106 -0
  29. package/lit-localize.json +21 -15
  30. package/localization/de.xlf +407 -74
  31. package/localization/es.xlf +407 -69
  32. package/localization/fr.xlf +407 -69
  33. package/localization/generated/de.ts +112 -29
  34. package/localization/generated/es.ts +112 -29
  35. package/localization/generated/fr.ts +112 -29
  36. package/localization/generated/index.d.ts +5 -0
  37. package/localization/generated/index.js +2 -2
  38. package/localization/generated/it.ts +112 -29
  39. package/localization/generated/locale-codes.js +31 -79
  40. package/localization/generated/nl.ts +112 -29
  41. package/localization/it.xlf +407 -69
  42. package/localization/nl.xlf +407 -69
  43. package/package.json +119 -252
  44. package/scripts/apply-translations.mjs +97 -0
  45. package/src/api/index.ts +72 -0
  46. package/src/lib/artboard.ts +686 -0
  47. package/src/lib/draggable-grid.ts +432 -0
  48. package/src/lib/encrypted-property.ts +202 -0
  49. package/src/lib/hint-popup.ts +152 -0
  50. package/src/lib/utils.ts +97 -0
  51. package/src/lib/webwriter-quirks.ts +121 -0
  52. package/src/migrate/index.ts +622 -0
  53. package/src/migrate/legacy-solution.ts +362 -0
  54. package/src/snippets/order.html +9 -8
  55. package/src/widgets/choice/webwriter-choice-item.ts +316 -0
  56. package/src/widgets/choice/webwriter-choice.ts +328 -0
  57. package/src/widgets/choice/webwriter-true-false.ts +113 -0
  58. package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
  59. package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
  60. package/src/widgets/dnd/webwriter-dnd.ts +36 -0
  61. package/src/widgets/gap/webwriter-gap-item.ts +250 -0
  62. package/src/widgets/gap/webwriter-gap.ts +447 -0
  63. package/src/widgets/order/webwriter-order-item.ts +243 -0
  64. package/src/widgets/order/webwriter-order.ts +282 -0
  65. package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
  66. package/src/widgets/pairing/webwriter-pairing.ts +934 -0
  67. package/src/widgets/speech/audio-player.ts +325 -0
  68. package/src/widgets/speech/audio-recorder.ts +285 -0
  69. package/src/widgets/speech/waveform.ts +113 -0
  70. package/src/widgets/speech/webwriter-speech.ts +87 -0
  71. package/src/widgets/webwriter-mark.ts +374 -432
  72. package/src/widgets/webwriter-quiz-hint.ts +36 -0
  73. package/src/widgets/webwriter-quiz.ts +320 -282
  74. package/src/widgets/webwriter-task-prompt.ts +49 -24
  75. package/src/widgets/webwriter-task.ts +216 -547
  76. package/src/widgets/webwriter-text.ts +291 -156
  77. package/tsconfig.api.json +12 -0
  78. package/tsconfig.json +8 -7
  79. package/dist/widgets/webwriter-choice.css +0 -444
  80. package/dist/widgets/webwriter-cloze-gap.css +0 -444
  81. package/dist/widgets/webwriter-cloze-gap.js +0 -5866
  82. package/dist/widgets/webwriter-cloze.css +0 -444
  83. package/dist/widgets/webwriter-cloze.js +0 -3786
  84. package/dist/widgets/webwriter-mark.css +0 -444
  85. package/dist/widgets/webwriter-order.css +0 -444
  86. package/dist/widgets/webwriter-pairing-item.css +0 -444
  87. package/dist/widgets/webwriter-pairing.css +0 -444
  88. package/dist/widgets/webwriter-quiz.css +0 -444
  89. package/dist/widgets/webwriter-task-explainer.css +0 -444
  90. package/dist/widgets/webwriter-task-explainer.js +0 -2872
  91. package/dist/widgets/webwriter-task-hint.css +0 -444
  92. package/dist/widgets/webwriter-task-hint.js +0 -2811
  93. package/dist/widgets/webwriter-task.css +0 -444
  94. package/dist/widgets/webwriter-text.css +0 -444
  95. package/localization/bg.xlf +0 -69
  96. package/localization/cs.xlf +0 -69
  97. package/localization/da.xlf +0 -69
  98. package/localization/el.xlf +0 -69
  99. package/localization/et.xlf +0 -69
  100. package/localization/fi.xlf +0 -69
  101. package/localization/generated/bg.ts +0 -30
  102. package/localization/generated/cs.ts +0 -30
  103. package/localization/generated/da.ts +0 -30
  104. package/localization/generated/el.ts +0 -30
  105. package/localization/generated/et.ts +0 -30
  106. package/localization/generated/fi.ts +0 -30
  107. package/localization/generated/hu.ts +0 -30
  108. package/localization/generated/id.ts +0 -30
  109. package/localization/generated/ja.ts +0 -30
  110. package/localization/generated/ko.ts +0 -30
  111. package/localization/generated/lt.ts +0 -30
  112. package/localization/generated/lv.ts +0 -30
  113. package/localization/generated/nb.ts +0 -30
  114. package/localization/generated/pl.ts +0 -30
  115. package/localization/generated/pt-BR.ts +0 -30
  116. package/localization/generated/pt-PT.ts +0 -30
  117. package/localization/generated/ro.ts +0 -30
  118. package/localization/generated/ru.ts +0 -30
  119. package/localization/generated/sk.ts +0 -30
  120. package/localization/generated/sl.ts +0 -30
  121. package/localization/generated/sv.ts +0 -30
  122. package/localization/generated/tr.ts +0 -30
  123. package/localization/generated/uk.ts +0 -30
  124. package/localization/generated/zh-hans.ts +0 -30
  125. package/localization/hu.xlf +0 -69
  126. package/localization/id.xlf +0 -69
  127. package/localization/ja.xlf +0 -69
  128. package/localization/ko.xlf +0 -69
  129. package/localization/lt.xlf +0 -69
  130. package/localization/lv.xlf +0 -69
  131. package/localization/nb.xlf +0 -69
  132. package/localization/pl.xlf +0 -69
  133. package/localization/pt-BR.xlf +0 -69
  134. package/localization/pt-PT.xlf +0 -69
  135. package/localization/ro.xlf +0 -69
  136. package/localization/ru.xlf +0 -69
  137. package/localization/sk.xlf +0 -69
  138. package/localization/sl.xlf +0 -69
  139. package/localization/sv.xlf +0 -69
  140. package/localization/tr.xlf +0 -69
  141. package/localization/uk.xlf +0 -69
  142. package/localization/zh-hans.xlf +0 -69
  143. package/src/lib/combobox.ts +0 -456
  144. package/src/lib/highlighter-fill.svg +0 -6
  145. package/src/snippets/Beispiel-Choice.html +0 -57
  146. package/src/snippets/Beispiel-Mark.html +0 -14
  147. package/src/snippets/Beispiel-Order.html +0 -52
  148. package/src/snippets/Beispiel-Text.html +0 -20
  149. package/src/snippets/blank.html +0 -1
  150. package/src/snippets/choice.html +0 -8
  151. package/src/snippets/cloze.html +0 -4
  152. package/src/snippets/mark.html +0 -4
  153. package/src/snippets/pairing.html +0 -10
  154. package/src/snippets/speech.html +0 -4
  155. package/src/snippets/text.html +0 -4
  156. package/src/snippets/wordsearch.html +0 -4
  157. package/src/widgets/webwriter-choice-item.ts +0 -254
  158. package/src/widgets/webwriter-choice.ts +0 -353
  159. package/src/widgets/webwriter-cloze-gap.ts +0 -216
  160. package/src/widgets/webwriter-cloze.ts +0 -136
  161. package/src/widgets/webwriter-order-item.ts +0 -441
  162. package/src/widgets/webwriter-order.ts +0 -327
  163. package/src/widgets/webwriter-pairing-item.ts +0 -161
  164. package/src/widgets/webwriter-pairing.ts +0 -193
  165. package/src/widgets/webwriter-speech.ts +0 -273
  166. package/src/widgets/webwriter-task-explainer.ts +0 -43
  167. package/src/widgets/webwriter-task-hint.ts +0 -23
@@ -0,0 +1,325 @@
1
+ import { msg, str } from "@lit/localize";
2
+ import { LitElementWw } from "@webwriter/lit";
3
+ import { css, html, PropertyValues } from "lit";
4
+ import { property, state } from "lit/decorators.js";
5
+ import { ref } from "lit/directives/ref.js";
6
+
7
+ import SlButtonGroup from "@shoelace-style/shoelace/dist/components/button-group/button-group.component.js";
8
+ import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js";
9
+ import SlDropdown from "@shoelace-style/shoelace/dist/components/dropdown/dropdown.component.js";
10
+ import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
11
+ import SlMenu from "@shoelace-style/shoelace/dist/components/menu/menu.component.js";
12
+ import SlRange from "@shoelace-style/shoelace/dist/components/range/range.component.js";
13
+
14
+ import PauseFillIcon from "bootstrap-icons/icons/pause-fill.svg";
15
+ import PlayFillIcon from "bootstrap-icons/icons/play-fill.svg";
16
+ import SlidersIcon from "bootstrap-icons/icons/sliders.svg";
17
+ import TrashIcon from "bootstrap-icons/icons/trash.svg";
18
+ import {
19
+ BAR_WIDTH,
20
+ formatDuration,
21
+ peakAmplitude,
22
+ SAMPLE_BAR_SPACING,
23
+ WaveformCanvas,
24
+ waveformStyles,
25
+ } from "./waveform";
26
+
27
+ const PLAYHEAD_RADIUS = 5;
28
+
29
+ export class AudioPlayer extends LitElementWw {
30
+ static scopedElements = {
31
+ "sl-button": SlButton,
32
+ "sl-icon": SlIcon,
33
+ "sl-button-group": SlButtonGroup,
34
+ "sl-dropdown": SlDropdown,
35
+ "sl-menu": SlMenu,
36
+ "sl-range": SlRange,
37
+ };
38
+
39
+ static styles = [
40
+ waveformStyles,
41
+ css`
42
+ :host {
43
+ display: flex;
44
+ gap: var(--sl-spacing-small);
45
+ align-items: center;
46
+ }
47
+
48
+ .duration {
49
+ white-space: nowrap;
50
+ }
51
+
52
+ .waveform {
53
+ min-width: 0;
54
+ cursor: pointer;
55
+ }
56
+
57
+ .options {
58
+ padding: var(--sl-spacing-small);
59
+ min-width: 170px;
60
+ background-color: var(--sl-color-neutral-0);
61
+ }
62
+ `,
63
+ ];
64
+
65
+ @property({ type: String, attribute: true, reflect: true })
66
+ accessor src: string | null = null;
67
+
68
+ @state() private accessor playing = false;
69
+ @state() private accessor currentTime = 0;
70
+ @state() private accessor duration = 0;
71
+ @state() private accessor volume = 100;
72
+ @state() private accessor playbackSpeed = 1;
73
+
74
+ private audioElement: HTMLAudioElement | null = null;
75
+ private audioContext: AudioContext | null = null;
76
+ private amplitudeSamples: number[] = [];
77
+ private animationFrameId: number | null = null;
78
+
79
+ private waveform = new WaveformCanvas();
80
+ private resizeObserver = new ResizeObserver(() => this.resizeCanvas());
81
+
82
+ disconnectedCallback() {
83
+ super.disconnectedCallback();
84
+ this.stopAnimationLoop();
85
+ this.resizeObserver.disconnect();
86
+ if (this.audioElement) {
87
+ this.audioElement.pause();
88
+ this.audioElement = null;
89
+ }
90
+ if (this.audioContext) {
91
+ this.audioContext.close();
92
+ this.audioContext = null;
93
+ }
94
+ }
95
+
96
+ updated(changedProperties: Map<string, unknown>) {
97
+ if (changedProperties.has("src") && this.src) {
98
+ this.loadAudio();
99
+ }
100
+ }
101
+
102
+ firstUpdated() {
103
+ this.resizeCanvas();
104
+ this.observeWaveform();
105
+ }
106
+
107
+ protected update(changedProperties: PropertyValues): void {
108
+ super.update(changedProperties);
109
+ this.observeWaveform();
110
+ }
111
+
112
+ /** Re-observing the same element is a no-op, so this can run on every update. */
113
+ private observeWaveform() {
114
+ const container = this.waveform.container.value;
115
+ if (container) this.resizeObserver.observe(container);
116
+ }
117
+
118
+ private resizeCanvas() {
119
+ if (this.waveform.resize()) this.renderWaveform();
120
+ }
121
+
122
+ private async loadAudio() {
123
+ if (!this.src) return;
124
+
125
+ // Create audio element for playback
126
+ this.audioElement = new Audio(this.src);
127
+ this.audioElement.volume = this.volume / 100;
128
+ this.audioElement.playbackRate = this.playbackSpeed;
129
+ this.audioElement.addEventListener("loadedmetadata", () => {
130
+ this.duration = this.audioElement!.duration;
131
+ });
132
+ // The animation loop only samples while playing, so the canvas would keep showing the
133
+ // position of its last frame when playback stops. Re-sync whenever the clock settles.
134
+ for (const event of ["play", "pause", "seeked"]) {
135
+ this.audioElement.addEventListener(event, () => this.syncCurrentTime());
136
+ }
137
+ this.audioElement.addEventListener("ended", () => {
138
+ this.playing = false;
139
+ this.stopAnimationLoop();
140
+ this.currentTime = 0;
141
+ this.audioElement!.currentTime = 0;
142
+ this.renderWaveform();
143
+ });
144
+
145
+ // Decode audio to generate waveform
146
+ await this.generateWaveformData();
147
+ this.renderWaveform();
148
+ }
149
+
150
+ private async generateWaveformData() {
151
+ if (!this.src) return;
152
+
153
+ try {
154
+ // Fetch and decode the audio data
155
+ const response = await fetch(this.src);
156
+ const arrayBuffer = await response.arrayBuffer();
157
+
158
+ this.audioContext = new AudioContext();
159
+ const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
160
+ const channelData = audioBuffer.getChannelData(0);
161
+
162
+ // Downsample the channel data to one peak per bar that fits into the canvas
163
+ const containerWidth = this.waveform.containerBounds?.width;
164
+ const targetSamples = containerWidth ? Math.ceil(containerWidth / SAMPLE_BAR_SPACING) : 200;
165
+ const samplesPerBar = Math.floor(channelData.length / targetSamples);
166
+
167
+ this.amplitudeSamples = [];
168
+ for (let i = 0; i < targetSamples; i++) {
169
+ const start = i * samplesPerBar;
170
+ const end = Math.min(start + samplesPerBar, channelData.length);
171
+ this.amplitudeSamples.push(peakAmplitude(channelData, start, end));
172
+ }
173
+
174
+ // Close audio context after decoding (we use HTMLAudioElement for playback)
175
+ this.audioContext.close();
176
+ this.audioContext = null;
177
+ } catch (e) {
178
+ console.error("Failed to generate waveform data:", e);
179
+ }
180
+ }
181
+
182
+ /** Horizontal extent the waveform occupies, inset so the playhead fits at either end. */
183
+ private extent(totalWidth: number) {
184
+ return { start: PLAYHEAD_RADIUS, span: Math.max(0, totalWidth - PLAYHEAD_RADIUS * 2) };
185
+ }
186
+
187
+ private renderWaveform() {
188
+ const ctx = this.waveform.begin();
189
+ if (!ctx || this.amplitudeSamples.length === 0) return;
190
+
191
+ const { height, activeColor, inactiveColor } = this.waveform;
192
+ const { start, span } = this.extent(this.waveform.width);
193
+ const barSpacing = span / this.amplitudeSamples.length;
194
+ const playbackProgress = this.duration > 0 ? this.currentTime / this.duration : 0;
195
+ const playbackX = start + playbackProgress * span;
196
+
197
+ this.amplitudeSamples.forEach((amplitude, i) => {
198
+ const x = start + i * barSpacing + barSpacing / 2;
199
+ this.waveform.drawBar(x, amplitude, inactiveColor);
200
+
201
+ // Redraw the played part of the bar on top, clipped to the playback position
202
+ if (x - BAR_WIDTH / 2 >= playbackX) return;
203
+ ctx.save();
204
+ ctx.beginPath();
205
+ ctx.rect(0, 0, playbackX, height);
206
+ ctx.clip();
207
+ this.waveform.drawBar(x, amplitude, activeColor);
208
+ ctx.restore();
209
+ });
210
+
211
+ ctx.beginPath();
212
+ ctx.arc(playbackX, height / 2, PLAYHEAD_RADIUS, 0, 2 * Math.PI);
213
+ ctx.fillStyle = activeColor;
214
+ ctx.fill();
215
+ }
216
+
217
+ /** Draws the position the media element is actually at, rather than the last sampled one. */
218
+ private syncCurrentTime() {
219
+ if (!this.audioElement) return;
220
+ this.currentTime = this.audioElement.currentTime;
221
+ this.renderWaveform();
222
+ }
223
+
224
+ private startAnimationLoop() {
225
+ const animate = () => {
226
+ if (!this.playing || !this.audioElement) return;
227
+ this.currentTime = this.audioElement.currentTime;
228
+ this.renderWaveform();
229
+ this.animationFrameId = requestAnimationFrame(animate);
230
+ };
231
+ this.animationFrameId = requestAnimationFrame(animate);
232
+ }
233
+
234
+ private stopAnimationLoop() {
235
+ if (this.animationFrameId !== null) {
236
+ cancelAnimationFrame(this.animationFrameId);
237
+ this.animationFrameId = null;
238
+ }
239
+ }
240
+
241
+ private togglePlayPause() {
242
+ if (!this.audioElement) return;
243
+
244
+ if (this.playing) {
245
+ this.audioElement.pause();
246
+ this.playing = false;
247
+ this.stopAnimationLoop();
248
+ this.syncCurrentTime();
249
+ } else {
250
+ // Resume where the playhead was actually drawn: pausing makes the media element drop
251
+ // the audio it had buffered but not played yet, so its clock would otherwise pick back
252
+ // up an output buffer's worth further ahead and the playhead would jump.
253
+ this.audioElement.currentTime = this.currentTime;
254
+ this.audioElement.play();
255
+ this.playing = true;
256
+ this.startAnimationLoop();
257
+ }
258
+ }
259
+
260
+ private handleWaveformClick(e: MouseEvent) {
261
+ const bounds = this.waveform.containerBounds;
262
+ if (!this.audioElement || this.duration === 0 || !bounds) return;
263
+
264
+ const { start, span } = this.extent(bounds.width);
265
+ const clickX = Math.min(Math.max(e.clientX - bounds.left, start), bounds.width - PLAYHEAD_RADIUS);
266
+ const progress = (clickX - start) / Math.max(1, span);
267
+
268
+ this.audioElement.currentTime = progress * this.duration;
269
+ this.currentTime = this.audioElement.currentTime;
270
+ this.renderWaveform();
271
+ }
272
+
273
+ private handleVolumeChange(e: Event) {
274
+ this.volume = Number((e.target as HTMLInputElement).value);
275
+ if (this.audioElement) this.audioElement.volume = this.volume / 100;
276
+ }
277
+
278
+ private handleSpeedChange(e: Event) {
279
+ this.playbackSpeed = Number((e.target as HTMLInputElement).value);
280
+ if (this.audioElement) this.audioElement.playbackRate = this.playbackSpeed;
281
+ }
282
+
283
+ render() {
284
+ return html`
285
+ <sl-button variant="primary" circle @click=${this.togglePlayPause}>
286
+ <sl-icon src=${this.playing ? PauseFillIcon : PlayFillIcon}></sl-icon>
287
+ </sl-button>
288
+ <span class="duration">${formatDuration(this.currentTime)} / ${formatDuration(this.duration)}</span>
289
+ <div class="waveform" ${ref(this.waveform.container)} @click=${this.handleWaveformClick}>
290
+ <canvas ${ref(this.waveform.canvas)}></canvas>
291
+ </div>
292
+ <sl-button-group>
293
+ <sl-dropdown placement="bottom-end">
294
+ <sl-button slot="trigger" caret>
295
+ <sl-icon src=${SlidersIcon}></sl-icon>
296
+ </sl-button>
297
+ <div class="options">
298
+ <sl-range
299
+ label=${msg(str`Volume: ${this.volume}%`)}
300
+ min="0"
301
+ max="100"
302
+ value=${this.volume}
303
+ tooltip="none"
304
+ @sl-input=${this.handleVolumeChange}
305
+ ></sl-range>
306
+ <sl-range
307
+ label=${msg(str`Speed: ${this.playbackSpeed}x`)}
308
+ min="0.5"
309
+ max="2"
310
+ step="0.1"
311
+ tooltip="none"
312
+ value=${this.playbackSpeed}
313
+ @sl-input=${this.handleSpeedChange}
314
+ ></sl-range>
315
+ </div>
316
+ </sl-dropdown>
317
+ <sl-button
318
+ @click=${() => this.dispatchEvent(new CustomEvent("delete-recording", { bubbles: true, composed: true }))}
319
+ >
320
+ <sl-icon src=${TrashIcon}></sl-icon>
321
+ </sl-button>
322
+ </sl-button-group>
323
+ `;
324
+ }
325
+ }
@@ -0,0 +1,285 @@
1
+ import { msg } from "@lit/localize";
2
+ import { LitElementWw } from "@webwriter/lit";
3
+ import { css, html } from "lit";
4
+ import { state } from "lit/decorators.js";
5
+ import { ref } from "lit/directives/ref.js";
6
+
7
+ import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js";
8
+ import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
9
+
10
+ import ExclamationCircle from "bootstrap-icons/icons/exclamation-circle.svg";
11
+ import MicFillIcon from "bootstrap-icons/icons/mic-fill.svg";
12
+ import PauseFillIcon from "bootstrap-icons/icons/pause-fill.svg";
13
+ import PlayFillIcon from "bootstrap-icons/icons/play-fill.svg";
14
+ import StopFillIcon from "bootstrap-icons/icons/stop-fill.svg";
15
+ import { formatDuration, peakAmplitude, SAMPLE_BAR_SPACING, WaveformCanvas, waveformStyles } from "./waveform";
16
+
17
+ const SAMPLES_PER_SECOND = 15;
18
+ const FADE_WIDTH = 20;
19
+
20
+ export class AudioRecorder extends LitElementWw {
21
+ static scopedElements = {
22
+ "sl-button": SlButton,
23
+ "sl-icon": SlIcon,
24
+ };
25
+
26
+ static styles = [
27
+ waveformStyles,
28
+ css`
29
+ :host {
30
+ display: flex;
31
+ gap: var(--sl-spacing-x-small);
32
+ align-items: center;
33
+ }
34
+
35
+ .error {
36
+ color: var(--sl-color-danger-600);
37
+ gap: 0.3em;
38
+ }
39
+
40
+ .info,
41
+ .error {
42
+ flex-grow: 1;
43
+ display: flex;
44
+ align-items: center;
45
+ }
46
+ `,
47
+ ];
48
+
49
+ @state() private accessor recording = false;
50
+ @state() private accessor paused = false;
51
+ @state() private accessor errorMessage: string | null = null;
52
+ @state() private accessor duration = "00:00";
53
+
54
+ private mediaStream!: MediaStream;
55
+ private mediaRecorder!: MediaRecorder;
56
+ private audioChunks: Blob[] = [];
57
+
58
+ // Audio analysis for waveform visualization
59
+ private audioContext: AudioContext | null = null;
60
+ private analyser: AnalyserNode | null = null;
61
+ private analyserDataArray: Uint8Array | null = null;
62
+ private amplitudeSamples: number[] = [];
63
+ private lastSampleTime: number = 0;
64
+ private sampleProgress: number = 0;
65
+ private sampleIntervalId: number | null = null;
66
+
67
+ private recordingStartTime: number = 0;
68
+ private recordingTimeOffset: number = 0;
69
+ private pausedAt: number = 0;
70
+
71
+ private waveform = new WaveformCanvas();
72
+
73
+ private sampleAmplitude = () => {
74
+ if (!this.analyser || !this.analyserDataArray) return;
75
+
76
+ // Get time domain data (waveform), where 0-255 is the sample value and 128 is silence
77
+ // @ts-ignore
78
+ this.analyser.getByteTimeDomainData(this.analyserDataArray);
79
+
80
+ // Store the peak amplitude of this window (clamped to 0-1) and its timestamp
81
+ const peak = peakAmplitude(this.analyserDataArray, 0, this.analyserDataArray.length, 128, 128);
82
+ this.amplitudeSamples.push(Math.min(1, peak * 2));
83
+ this.lastSampleTime = Date.now();
84
+ };
85
+
86
+ private startSampling() {
87
+ // Continue where the last interval left off, so the waveform does not jump on resume
88
+ this.lastSampleTime = Date.now() - this.sampleProgress;
89
+ this.sampleIntervalId = window.setInterval(this.sampleAmplitude, 1000 / SAMPLES_PER_SECOND);
90
+ }
91
+
92
+ private stopSampling() {
93
+ if (this.sampleIntervalId === null) return;
94
+
95
+ this.sampleProgress = Date.now() - this.lastSampleTime;
96
+ clearInterval(this.sampleIntervalId);
97
+ this.sampleIntervalId = null;
98
+ }
99
+
100
+ private async startRecording() {
101
+ try {
102
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
103
+ audio: true,
104
+ });
105
+ } catch (e) {
106
+ if (e instanceof Error && e.name === "NotAllowedError") {
107
+ this.errorMessage = msg("WebWriter does not have permission to access the microphone.");
108
+ } else {
109
+ this.errorMessage = msg("An error occurred while accessing the microphone.");
110
+ }
111
+ return;
112
+ }
113
+ this.mediaRecorder = new MediaRecorder(this.mediaStream);
114
+
115
+ this.audioChunks = [];
116
+ this.mediaRecorder.ondataavailable = event => {
117
+ if (event.data.size > 0) this.audioChunks.push(event.data);
118
+ };
119
+ this.mediaRecorder.onstop = () => {
120
+ const audioBlob = new Blob(this.audioChunks, { type: "audio/webm" });
121
+ this.dispatchEvent(
122
+ new CustomEvent("recording-complete", {
123
+ detail: { audioBlob },
124
+ bubbles: true,
125
+ composed: true,
126
+ }),
127
+ );
128
+ };
129
+
130
+ this.mediaRecorder.start();
131
+
132
+ // Set up audio analysis for waveform visualization
133
+ this.audioContext = new AudioContext();
134
+ const source = this.audioContext.createMediaStreamSource(this.mediaStream);
135
+ this.analyser = this.audioContext.createAnalyser();
136
+ this.analyser.fftSize = 256;
137
+ source.connect(this.analyser);
138
+ this.analyserDataArray = new Uint8Array(this.analyser.frequencyBinCount);
139
+ this.amplitudeSamples = [];
140
+ this.sampleProgress = 0;
141
+ this.startSampling();
142
+
143
+ this.recordingStartTime = Date.now();
144
+ this.recordingTimeOffset = 0;
145
+
146
+ this.recording = true;
147
+ await this.updateComplete;
148
+ this.waveform.resize();
149
+ requestAnimationFrame(this.renderWaveform);
150
+ }
151
+
152
+ private stopRecording() {
153
+ // this.recording = false
154
+ this.paused = false;
155
+ this.mediaRecorder.stop();
156
+ this.mediaStream.getTracks().forEach(track => track.stop());
157
+
158
+ // Clean up audio analysis resources
159
+ this.stopSampling();
160
+ if (this.audioContext) {
161
+ this.audioContext.close();
162
+ this.audioContext = null;
163
+ }
164
+ this.analyser = null;
165
+ this.analyserDataArray = null;
166
+ }
167
+
168
+ private pauseRecording() {
169
+ if (!this.recording || this.paused) return;
170
+
171
+ this.paused = true;
172
+ this.pausedAt = Date.now();
173
+
174
+ this.mediaRecorder.pause();
175
+ this.stopSampling();
176
+
177
+ // Suspend audio context to save resources
178
+ if (this.audioContext) this.audioContext.suspend();
179
+ }
180
+
181
+ private resumeRecording() {
182
+ if (!this.recording || !this.paused) return;
183
+
184
+ // Add the paused duration to the offset so time calculations remain correct
185
+ this.recordingTimeOffset += this.pausedAt - this.recordingStartTime;
186
+ this.recordingStartTime = Date.now();
187
+
188
+ this.mediaRecorder.resume();
189
+ if (this.audioContext) this.audioContext.resume();
190
+ this.startSampling();
191
+
192
+ this.paused = false;
193
+ requestAnimationFrame(this.renderWaveform);
194
+ }
195
+
196
+ private renderWaveform = () => {
197
+ if (!this.recording || this.paused) return;
198
+
199
+ const recordingTimeMs = Date.now() - this.recordingStartTime + this.recordingTimeOffset;
200
+ this.duration = formatDuration(recordingTimeMs / 1000);
201
+
202
+ const ctx = this.waveform.begin();
203
+ if (!ctx) return;
204
+
205
+ const { width, height, activeColor, inactiveColor, backgroundColor } = this.waveform;
206
+
207
+ // Shift all bars by the fraction of a sample interval that has passed since the last sample,
208
+ // so the waveform scrolls smoothly instead of jumping from sample to sample
209
+ const msSinceLastSample = Date.now() - this.lastSampleTime;
210
+ const fractionalPart = Math.min(1, msSinceLastSample / (1000 / SAMPLES_PER_SECOND));
211
+ const offset = fractionalPart * SAMPLE_BAR_SPACING;
212
+
213
+ // Draw bars by index relative to the center, not by pixel position:
214
+ // i = 0 is the center (most recent sample), negative = past, positive = future
215
+ const centerX = width / 2;
216
+ const barsOnLeft = Math.ceil(centerX / SAMPLE_BAR_SPACING) + 1;
217
+ const barsOnRight = Math.ceil((width - centerX) / SAMPLE_BAR_SPACING) + 1;
218
+
219
+ for (let i = -barsOnLeft; i <= barsOnRight; i++) {
220
+ const x = centerX + i * SAMPLE_BAR_SPACING - offset;
221
+ const sampleIndex = this.amplitudeSamples.length - 1 + i;
222
+
223
+ // Future bars and past bars without samples are flat and gray, the rest use their sample
224
+ const hasSample = i <= 0 && sampleIndex >= 0;
225
+ this.waveform.drawBar(
226
+ x,
227
+ hasSample ? this.amplitudeSamples[sampleIndex] : 0,
228
+ hasSample ? activeColor : inactiveColor,
229
+ );
230
+ }
231
+
232
+ // Center line
233
+ ctx.beginPath();
234
+ ctx.moveTo(centerX, 0);
235
+ ctx.lineTo(centerX, height);
236
+ ctx.strokeStyle = inactiveColor;
237
+ ctx.lineWidth = 1;
238
+ ctx.stroke();
239
+
240
+ // Fade the waveform out towards both edges
241
+ for (const [from, to, fadeX] of [
242
+ [backgroundColor, "rgba(255, 255, 255, 0)", 0],
243
+ ["rgba(255, 255, 255, 0)", backgroundColor, width - FADE_WIDTH],
244
+ ] as const) {
245
+ const gradient = ctx.createLinearGradient(fadeX, 0, fadeX + FADE_WIDTH, 0);
246
+ gradient.addColorStop(0, from);
247
+ gradient.addColorStop(1, to);
248
+ ctx.fillStyle = gradient;
249
+ ctx.fillRect(fadeX, 0, FADE_WIDTH, height);
250
+ }
251
+
252
+ requestAnimationFrame(this.renderWaveform);
253
+ };
254
+
255
+ render() {
256
+ let content = null;
257
+
258
+ if (!this.recording) {
259
+ if (this.errorMessage) {
260
+ content = html`<span class="error"><sl-icon src=${ExclamationCircle}></sl-icon>${this.errorMessage}</span>`;
261
+ } else {
262
+ content = html`<span class="info">${msg("Click to Start Recording")}</span>`;
263
+ }
264
+ } else {
265
+ content = html`
266
+ <sl-button circle @click=${() => (this.paused ? this.resumeRecording() : this.pauseRecording())}>
267
+ <sl-icon src=${this.paused ? PlayFillIcon : PauseFillIcon}></sl-icon>
268
+ </sl-button>
269
+ <span class="duration">${this.duration}</span>
270
+ <div class="waveform" ${ref(this.waveform.container)}>
271
+ <canvas ${ref(this.waveform.canvas)}></canvas>
272
+ </div>
273
+ `;
274
+ }
275
+
276
+ return html`<sl-button
277
+ variant="primary"
278
+ circle
279
+ @click=${() => (this.recording ? this.stopRecording() : this.startRecording())}
280
+ >
281
+ <sl-icon src=${this.recording ? StopFillIcon : MicFillIcon}></sl-icon>
282
+ </sl-button>
283
+ ${content}`;
284
+ }
285
+ }