Skip to content

Commit e0da930

Browse files
committed
changes
1 parent 89474de commit e0da930

44 files changed

Lines changed: 753 additions & 81 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Meta } from "@storybook/blocks";
2+
3+
<Meta title="Response Items/Camera/Attribute Reference" />
4+
5+
# `<aa-camera>` Attribute Reference
6+
7+
A camera capture response item that provides a live video preview with controls to capture a photo, retake, and flip between front and rear cameras. The captured image is stored as a base64 JPEG data URL.
8+
9+
```html
10+
<aa-screen submit-button-text="Next">
11+
<aa-label>Please take a photo of your meal</aa-label>
12+
<aa-camera name="meal-photo" facing="environment"></aa-camera>
13+
</aa-screen>
14+
```
15+
16+
---
17+
18+
## Attributes
19+
20+
### `name`
21+
22+
- **Type:** String
23+
- **Default:**
24+
25+
The variable name under which the captured photo data URL is stored in session memory.
26+
27+
### `facing`
28+
29+
- **Type:** String
30+
- **Default:** `"environment"`
31+
- **Allowed values:** `"environment"` · `"user"`
32+
33+
The initial camera direction to use.
34+
35+
- **`environment`** — Rear-facing camera (default). Best for capturing objects, food, surroundings.
36+
- **`user`** — Front-facing camera. Best for selfies.
37+
38+
The participant can also toggle between cameras using the Flip button.
39+
40+
### `width`
41+
42+
- **Type:** Number
43+
- **Default:** `640`
44+
45+
The pixel width of the captured image. This sets the canvas resolution used when capturing a frame from the video stream.
46+
47+
### `height`
48+
49+
- **Type:** Number
50+
- **Default:** `480`
51+
52+
The pixel height of the captured image. This sets the canvas resolution used when capturing a frame from the video stream.
53+
54+
### `value`
55+
56+
- **Type:** String
57+
- **Default:**
58+
- **User-defined:** No (read-only)
59+
60+
The captured photo as a base64 JPEG data URL (e.g. `data:image/jpeg;base64,...`). This is `null` until the participant captures a photo. Cleared on retake.
61+
62+
---
63+
64+
## Attribute Summary Table
65+
66+
| Attribute | Type | Default | Description |
67+
|-----------|---------|-----------------|------------------------------------------|
68+
| `name` | String || Variable name for data storage |
69+
| `facing` | String | `"environment"` | Initial camera direction |
70+
| `width` | Number | `640` | Capture resolution width in pixels |
71+
| `height` | Number | `480` | Capture resolution height in pixels |
72+
| `value` | String || Captured base64 data URL (read-only) |
73+
74+
---
75+
76+
## DOM API
77+
78+
```js
79+
const cam = document.querySelector('aa-camera');
80+
console.log(cam.value); // base64 data URL or null
81+
```
82+
83+
| Property | Type | Description |
84+
|----------|-------------------|--------------------------------------------------|
85+
| `value` | `string \| null` | The captured base64 JPEG data URL |
86+
| `facing` | `string` | Current camera direction (`"environment"` or `"user"`) |
87+
88+
### Events
89+
90+
| Event | Description |
91+
|----------|------------------------------------|
92+
| `change` | Fired after a photo is captured |
93+
94+
The parent `<aa-screen>` collects the final value on submission.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { AABaseElement, html, type AAPropertiesMap } from '../aa-base-element/aa-base-element';
2+
3+
export class AACamera extends AABaseElement {
4+
5+
static get category(): string {
6+
return "response item";
7+
}
8+
9+
static get tag(): string {
10+
return 'aa-camera';
11+
}
12+
13+
static get properties(): AAPropertiesMap {
14+
return {
15+
name: {
16+
type: String,
17+
userDefined: true
18+
},
19+
'facing': {
20+
type: String,
21+
userDefined: true,
22+
value: "environment",
23+
valuesAllowed: ["environment", "user"]
24+
},
25+
'value': {
26+
type: String,
27+
userDefined: false
28+
},
29+
'width': {
30+
type: Number,
31+
userDefined: true,
32+
value: 640
33+
},
34+
'height': {
35+
type: Number,
36+
userDefined: true,
37+
value: 480
38+
}
39+
}
40+
}
41+
42+
static get acceptsElements(): null {
43+
return null;
44+
}
45+
46+
static get observedAttributes(): string[] {
47+
return Object.keys(AACamera.properties);
48+
}
49+
50+
root: ShadowRoot;
51+
_stream: MediaStream | null = null;
52+
_videoEl: HTMLVideoElement | null = null;
53+
_canvasEl: HTMLCanvasElement | null = null;
54+
_imgEl: HTMLImageElement | null = null;
55+
_captureBtn: HTMLButtonElement | null = null;
56+
_retakeBtn: HTMLButtonElement | null = null;
57+
_flipBtn: HTMLButtonElement | null = null;
58+
59+
get value(): string | null {
60+
return this.getAttribute('value');
61+
}
62+
63+
set value(val: string | null) {
64+
if (val) {
65+
this.setAttribute('value', val);
66+
} else {
67+
this.removeAttribute('value');
68+
}
69+
let memory = this.getMemory();
70+
if (memory) memory.setData(AABaseElement.getVariableName(this), val);
71+
}
72+
73+
constructor() {
74+
super();
75+
this.root = this.attachShadow({ mode: 'open' });
76+
}
77+
78+
connectedCallback(): void {
79+
super.connectedCallback();
80+
this.root.innerHTML = this.css + this.htmlTemplate;
81+
82+
this._videoEl = this.root.querySelector('.preview') as HTMLVideoElement;
83+
this._canvasEl = this.root.querySelector('.canvas') as HTMLCanvasElement;
84+
this._imgEl = this.root.querySelector('.captured') as HTMLImageElement;
85+
this._captureBtn = this.root.querySelector('.capture-btn') as HTMLButtonElement;
86+
this._retakeBtn = this.root.querySelector('.retake-btn') as HTMLButtonElement;
87+
this._flipBtn = this.root.querySelector('.flip-btn') as HTMLButtonElement;
88+
89+
this._captureBtn.addEventListener('click', () => this._capture());
90+
this._retakeBtn.addEventListener('click', () => this._retake());
91+
this._flipBtn.addEventListener('click', () => this._flipCamera());
92+
93+
this._startCamera();
94+
}
95+
96+
disconnectedCallback(): void {
97+
this._stopStream();
98+
}
99+
100+
async _startCamera(): Promise<void> {
101+
this._stopStream();
102+
try {
103+
const facingMode = this.getAttribute('facing') || 'environment';
104+
this._stream = await navigator.mediaDevices.getUserMedia({
105+
video: { facingMode: facingMode }
106+
});
107+
if (this._videoEl) {
108+
this._videoEl.srcObject = this._stream;
109+
}
110+
} catch (err) {
111+
console.error('aa-camera: could not access camera', err);
112+
}
113+
}
114+
115+
_stopStream(): void {
116+
if (this._stream) {
117+
this._stream.getTracks().forEach(track => track.stop());
118+
this._stream = null;
119+
}
120+
}
121+
122+
_capture(): void {
123+
if (!this._videoEl || !this._canvasEl || !this._imgEl) return;
124+
125+
const w = Number(this.getAttribute('width')) || 640;
126+
const h = Number(this.getAttribute('height')) || 480;
127+
this._canvasEl.width = w;
128+
this._canvasEl.height = h;
129+
130+
const ctx = this._canvasEl.getContext('2d');
131+
if (!ctx) return;
132+
ctx.drawImage(this._videoEl, 0, 0, w, h);
133+
134+
const dataUrl = this._canvasEl.toDataURL('image/jpeg');
135+
this.value = dataUrl;
136+
137+
this._imgEl.src = dataUrl;
138+
this._imgEl.style.display = 'block';
139+
this._videoEl.style.display = 'none';
140+
141+
if (this._captureBtn) this._captureBtn.style.display = 'none';
142+
if (this._flipBtn) this._flipBtn.style.display = 'none';
143+
if (this._retakeBtn) this._retakeBtn.style.display = 'inline-block';
144+
145+
this.dispatchEvent(new CustomEvent('change'));
146+
}
147+
148+
_retake(): void {
149+
this.value = null;
150+
151+
if (this._imgEl) {
152+
this._imgEl.style.display = 'none';
153+
this._imgEl.src = '';
154+
}
155+
if (this._videoEl) {
156+
this._videoEl.style.display = 'block';
157+
}
158+
if (this._captureBtn) this._captureBtn.style.display = 'inline-block';
159+
if (this._flipBtn) this._flipBtn.style.display = 'inline-block';
160+
if (this._retakeBtn) this._retakeBtn.style.display = 'none';
161+
162+
this._startCamera();
163+
}
164+
165+
_flipCamera(): void {
166+
const current = this.getAttribute('facing') || 'environment';
167+
const next = current === 'environment' ? 'user' : 'environment';
168+
this.setAttribute('facing', next);
169+
this._startCamera();
170+
}
171+
172+
get css(): string {
173+
return html`<style>
174+
:host {
175+
display: block;
176+
overflow: hidden;
177+
}
178+
.container {
179+
display: flex;
180+
flex-direction: column;
181+
align-items: center;
182+
gap: 12px;
183+
}
184+
.preview, .captured {
185+
width: 100%;
186+
max-width: 640px;
187+
border-radius: 8px;
188+
background: #000;
189+
object-fit: cover;
190+
}
191+
.canvas {
192+
display: none;
193+
}
194+
.captured {
195+
display: none;
196+
}
197+
.controls {
198+
display: flex;
199+
gap: 12px;
200+
justify-content: center;
201+
flex-wrap: wrap;
202+
}
203+
.controls button {
204+
font-family: sans-serif;
205+
font-size: 16px;
206+
padding: 10px 20px;
207+
border: 1px solid #ccc;
208+
border-radius: 6px;
209+
background: #fff;
210+
cursor: pointer;
211+
transition: background 0.15s;
212+
}
213+
.controls button:hover {
214+
background: #f0f0f0;
215+
}
216+
.capture-btn {
217+
background: #4CAF50 !important;
218+
color: #fff;
219+
border-color: #4CAF50 !important;
220+
}
221+
.capture-btn:hover {
222+
background: #43A047 !important;
223+
}
224+
</style>`;
225+
}
226+
227+
get htmlTemplate(): string {
228+
return html`
229+
<div class="container">
230+
<video class="preview" autoplay playsinline></video>
231+
<canvas class="canvas"></canvas>
232+
<img class="captured" alt="Captured photo">
233+
<div class="controls">
234+
<button class="flip-btn" type="button">\u27F2 Flip</button>
235+
<button class="capture-btn" type="button">\uD83D\uDCF7 Capture</button>
236+
<button class="retake-btn" type="button" style="display:none">\u21BA Retake</button>
237+
</div>
238+
</div>`;
239+
}
240+
}
241+
242+
AABaseElement.registerAAElement('aa-camera', AACamera);

src/customElements/aa-signal-protocol/aa-signal-protocol.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@ function parseTime(str: string): { hours: number; minutes: number } {
2828
if (!match) {
2929
throw new Error(`Invalid time format: "${str}". Expected HH:MM`);
3030
}
31-
return { hours: parseInt(match[1], 10), minutes: parseInt(match[2], 10) };
31+
const hours = parseInt(match[1], 10);
32+
const minutes = parseInt(match[2], 10);
33+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
34+
throw new Error(
35+
`Invalid time value: "${str}". Hours must be 0-23 and minutes must be 0-59`
36+
);
37+
}
38+
return { hours, minutes };
3239
}
3340

3441
/**
@@ -41,10 +48,14 @@ function parseTimeRange(str: string): { startMinutes: number; endMinutes: number
4148
}
4249
const start = parseTime(parts[0]);
4350
const end = parseTime(parts[1]);
44-
return {
45-
startMinutes: start.hours * 60 + start.minutes,
46-
endMinutes: end.hours * 60 + end.minutes,
47-
};
51+
const startMinutes = start.hours * 60 + start.minutes;
52+
const endMinutes = end.hours * 60 + end.minutes;
53+
if (endMinutes <= startMinutes) {
54+
throw new Error(
55+
`Invalid time range: "${str}". End time must be after start time (cross-midnight ranges are not supported)`
56+
);
57+
}
58+
return { startMinutes, endMinutes };
4859
}
4960

5061
/**
@@ -245,7 +256,11 @@ export class AASignalProtocol extends AABaseElement {
245256

246257
connectedCallback(): void {
247258
super.connectedCallback();
248-
this.validate();
259+
try {
260+
this.validate();
261+
} catch (error) {
262+
console.error('[aa-signal-protocol] Failed to validate protocol configuration:', error);
263+
}
249264
setTimeout(() => {
250265
this.dispatchEvent(new CustomEvent('protocolReady', { bubbles: true }));
251266
}, 0);
@@ -593,6 +608,13 @@ export class AASignalProtocol extends AABaseElement {
593608
attempts++;
594609
}
595610

611+
const stillTooClose = signals.some(s => Math.abs(s - minuteOfDay) < minIntervalMinutes);
612+
if (stillTooClose) {
613+
console.warn(
614+
`[aa-signal-protocol] Could not satisfy min-interval constraint after ${attempts} retries; ` +
615+
`relaxing constraint for one signal (minInterval=${minIntervalMinutes}min, minuteOfDay=${Math.round(minuteOfDay)})`
616+
);
617+
}
596618
signals.push(minuteOfDay);
597619
}
598620

0 commit comments

Comments
 (0)