@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.
- package/.github/workflows/publish.yml +8 -0
- package/LICENSE +6 -6
- package/README.md +778 -0
- package/assets/icons/grip-horizontal.svg +8 -0
- package/assets/icons/grip-vertical.svg +8 -0
- package/assets/icons/truefalse.svg +6 -0
- package/custom-elements.json +2779 -0
- package/custom.d.ts +180 -176
- package/dist/api/index.d.ts +27 -0
- package/dist/api/index.js +40 -0
- package/dist/migrate.js +1434 -0
- package/dist/widgets/webwriter-choice-item.js +4036 -3895
- package/dist/widgets/webwriter-choice.js +2269 -5821
- package/dist/widgets/webwriter-gap-item.js +3987 -0
- package/dist/widgets/webwriter-gap.js +2908 -0
- package/dist/widgets/webwriter-mark.js +4335 -3489
- package/dist/widgets/webwriter-order-item.js +4014 -3306
- package/dist/widgets/webwriter-order.js +2944 -4386
- package/dist/widgets/webwriter-pairing-item.js +1058 -2421
- package/dist/widgets/webwriter-pairing.js +2993 -4383
- package/dist/widgets/webwriter-quiz-hint.js +2469 -0
- package/dist/widgets/webwriter-quiz.js +4933 -6099
- package/dist/widgets/webwriter-speech.js +6077 -5230
- package/dist/widgets/webwriter-task-prompt.js +4775 -2165
- package/dist/widgets/webwriter-task.js +2364 -8382
- package/dist/widgets/webwriter-text.js +4169 -4337
- package/dist/widgets/webwriter-true-false.js +4674 -0
- package/editing-config.json +106 -0
- package/lit-localize.json +21 -15
- package/localization/de.xlf +407 -74
- package/localization/es.xlf +407 -69
- package/localization/fr.xlf +407 -69
- package/localization/generated/de.ts +112 -29
- package/localization/generated/es.ts +112 -29
- package/localization/generated/fr.ts +112 -29
- package/localization/generated/index.d.ts +5 -0
- package/localization/generated/index.js +2 -2
- package/localization/generated/it.ts +112 -29
- package/localization/generated/locale-codes.js +31 -79
- package/localization/generated/nl.ts +112 -29
- package/localization/it.xlf +407 -69
- package/localization/nl.xlf +407 -69
- package/package.json +119 -252
- package/scripts/apply-translations.mjs +97 -0
- package/src/api/index.ts +72 -0
- package/src/lib/artboard.ts +686 -0
- package/src/lib/draggable-grid.ts +432 -0
- package/src/lib/encrypted-property.ts +202 -0
- package/src/lib/hint-popup.ts +152 -0
- package/src/lib/utils.ts +97 -0
- package/src/lib/webwriter-quirks.ts +121 -0
- package/src/migrate/index.ts +622 -0
- package/src/migrate/legacy-solution.ts +362 -0
- package/src/snippets/order.html +9 -8
- package/src/widgets/choice/webwriter-choice-item.ts +316 -0
- package/src/widgets/choice/webwriter-choice.ts +328 -0
- package/src/widgets/choice/webwriter-true-false.ts +113 -0
- package/src/widgets/dnd/webwriter-dnd-draggable.ts +28 -0
- package/src/widgets/dnd/webwriter-dnd-dropzone.ts +9 -0
- package/src/widgets/dnd/webwriter-dnd.ts +36 -0
- package/src/widgets/gap/webwriter-gap-item.ts +250 -0
- package/src/widgets/gap/webwriter-gap.ts +447 -0
- package/src/widgets/order/webwriter-order-item.ts +243 -0
- package/src/widgets/order/webwriter-order.ts +282 -0
- package/src/widgets/pairing/webwriter-pairing-item.ts +46 -0
- package/src/widgets/pairing/webwriter-pairing.ts +934 -0
- package/src/widgets/speech/audio-player.ts +325 -0
- package/src/widgets/speech/audio-recorder.ts +285 -0
- package/src/widgets/speech/waveform.ts +113 -0
- package/src/widgets/speech/webwriter-speech.ts +87 -0
- package/src/widgets/webwriter-mark.ts +374 -432
- package/src/widgets/webwriter-quiz-hint.ts +36 -0
- package/src/widgets/webwriter-quiz.ts +320 -282
- package/src/widgets/webwriter-task-prompt.ts +49 -24
- package/src/widgets/webwriter-task.ts +216 -547
- package/src/widgets/webwriter-text.ts +291 -156
- package/tsconfig.api.json +12 -0
- package/tsconfig.json +8 -7
- package/dist/widgets/webwriter-choice.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.css +0 -444
- package/dist/widgets/webwriter-cloze-gap.js +0 -5866
- package/dist/widgets/webwriter-cloze.css +0 -444
- package/dist/widgets/webwriter-cloze.js +0 -3786
- package/dist/widgets/webwriter-mark.css +0 -444
- package/dist/widgets/webwriter-order.css +0 -444
- package/dist/widgets/webwriter-pairing-item.css +0 -444
- package/dist/widgets/webwriter-pairing.css +0 -444
- package/dist/widgets/webwriter-quiz.css +0 -444
- package/dist/widgets/webwriter-task-explainer.css +0 -444
- package/dist/widgets/webwriter-task-explainer.js +0 -2872
- package/dist/widgets/webwriter-task-hint.css +0 -444
- package/dist/widgets/webwriter-task-hint.js +0 -2811
- package/dist/widgets/webwriter-task.css +0 -444
- package/dist/widgets/webwriter-text.css +0 -444
- package/localization/bg.xlf +0 -69
- package/localization/cs.xlf +0 -69
- package/localization/da.xlf +0 -69
- package/localization/el.xlf +0 -69
- package/localization/et.xlf +0 -69
- package/localization/fi.xlf +0 -69
- package/localization/generated/bg.ts +0 -30
- package/localization/generated/cs.ts +0 -30
- package/localization/generated/da.ts +0 -30
- package/localization/generated/el.ts +0 -30
- package/localization/generated/et.ts +0 -30
- package/localization/generated/fi.ts +0 -30
- package/localization/generated/hu.ts +0 -30
- package/localization/generated/id.ts +0 -30
- package/localization/generated/ja.ts +0 -30
- package/localization/generated/ko.ts +0 -30
- package/localization/generated/lt.ts +0 -30
- package/localization/generated/lv.ts +0 -30
- package/localization/generated/nb.ts +0 -30
- package/localization/generated/pl.ts +0 -30
- package/localization/generated/pt-BR.ts +0 -30
- package/localization/generated/pt-PT.ts +0 -30
- package/localization/generated/ro.ts +0 -30
- package/localization/generated/ru.ts +0 -30
- package/localization/generated/sk.ts +0 -30
- package/localization/generated/sl.ts +0 -30
- package/localization/generated/sv.ts +0 -30
- package/localization/generated/tr.ts +0 -30
- package/localization/generated/uk.ts +0 -30
- package/localization/generated/zh-hans.ts +0 -30
- package/localization/hu.xlf +0 -69
- package/localization/id.xlf +0 -69
- package/localization/ja.xlf +0 -69
- package/localization/ko.xlf +0 -69
- package/localization/lt.xlf +0 -69
- package/localization/lv.xlf +0 -69
- package/localization/nb.xlf +0 -69
- package/localization/pl.xlf +0 -69
- package/localization/pt-BR.xlf +0 -69
- package/localization/pt-PT.xlf +0 -69
- package/localization/ro.xlf +0 -69
- package/localization/ru.xlf +0 -69
- package/localization/sk.xlf +0 -69
- package/localization/sl.xlf +0 -69
- package/localization/sv.xlf +0 -69
- package/localization/tr.xlf +0 -69
- package/localization/uk.xlf +0 -69
- package/localization/zh-hans.xlf +0 -69
- package/src/lib/combobox.ts +0 -456
- package/src/lib/highlighter-fill.svg +0 -6
- package/src/snippets/Beispiel-Choice.html +0 -57
- package/src/snippets/Beispiel-Mark.html +0 -14
- package/src/snippets/Beispiel-Order.html +0 -52
- package/src/snippets/Beispiel-Text.html +0 -20
- package/src/snippets/blank.html +0 -1
- package/src/snippets/choice.html +0 -8
- package/src/snippets/cloze.html +0 -4
- package/src/snippets/mark.html +0 -4
- package/src/snippets/pairing.html +0 -10
- package/src/snippets/speech.html +0 -4
- package/src/snippets/text.html +0 -4
- package/src/snippets/wordsearch.html +0 -4
- package/src/widgets/webwriter-choice-item.ts +0 -254
- package/src/widgets/webwriter-choice.ts +0 -353
- package/src/widgets/webwriter-cloze-gap.ts +0 -216
- package/src/widgets/webwriter-cloze.ts +0 -136
- package/src/widgets/webwriter-order-item.ts +0 -441
- package/src/widgets/webwriter-order.ts +0 -327
- package/src/widgets/webwriter-pairing-item.ts +0 -161
- package/src/widgets/webwriter-pairing.ts +0 -193
- package/src/widgets/webwriter-speech.ts +0 -273
- package/src/widgets/webwriter-task-explainer.ts +0 -43
- 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
|
+
}
|