@getdom/studio 0.1.1 → 0.2.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/CHANGELOG.md +31 -0
- package/README.md +31 -0
- package/SKILL.md +21 -1
- package/package.json +4 -2
- package/public/llms.txt +13 -2
- package/src/pages/_layout/componentCatalog.generated.js +1185 -44
- package/src/pages/_layout/componentLoaders.js +41 -0
- package/src/pages/_layout/componentManager.js +38 -27
- package/src/pages/_layout/componentRuntimeRecords.js +3 -1
- package/src/pages/charts/_shared/ChartContainer.vue +67 -0
- package/src/pages/charts/_shared/ChartLegend.vue +22 -0
- package/src/pages/charts/_shared/ChartTooltip.vue +18 -0
- package/src/pages/charts/_shared/DonutRenderer.vue +33 -0
- package/src/pages/charts/_shared/XYChart.vue +140 -0
- package/src/pages/charts/_shared/XYRenderer.vue +90 -0
- package/src/pages/charts/_shared/chartProps.js +23 -0
- package/src/pages/charts/_shared/chartUtils.js +95 -0
- package/src/pages/charts/area-chart/DomAreaChart.vue +27 -0
- package/src/pages/charts/bar-chart/DomBarChart.vue +29 -0
- package/src/pages/charts/date-range-picker/DomDateRangePicker.vue +104 -0
- package/src/pages/charts/date-range-picker/dateRange.js +69 -0
- package/src/pages/charts/donut-chart/DomDonutChart.vue +117 -0
- package/src/pages/charts/line-chart/DomLineChart.vue +26 -0
- package/src/pages/charts/metric-card/DomMetricCard.vue +49 -0
- package/src/pages/charts/sparkline/DomSparkline.vue +30 -0
- package/src/pages/components/_shared/media/MediaDeviceSelect.vue +28 -0
- package/src/pages/components/_shared/media/useMediaInput.js +200 -0
- package/src/pages/components/audio-controls/DomAudioControls.vue +91 -0
- package/src/pages/components/audio-input/DomAudioInput.vue +58 -0
- package/src/pages/components/button/DomButton.vue +10 -4
- package/src/pages/components/button/buttonStyles.js +5 -0
- package/src/pages/components/camera-input/DomCameraInput.vue +75 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +12 -2
- package/src/pages/components/dialog/DomDialog.vue +338 -36
- package/src/pages/components/icon-button/DomIconButton.vue +8 -2
- package/src/pages/components/menu-stack/DomMenuStack.vue +111 -0
- package/src/pages/components/menu-stack/menuTree.js +35 -0
- package/src/pages/components/popover/DomPopover.vue +1 -53
- package/src/pages/components/popover/popover.css +50 -0
- package/src/pages/components/video-feed/DomVideoFeed.vue +551 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +1 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +1 -0
- package/src/pages/data/gantt/DomGantt.vue +788 -0
- package/src/pages/data/gantt/ganttTime.js +223 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +84 -9
- package/src/pages/data/notification-inbox/DomNotificationInbox.vue +505 -0
- package/src/pages/data/notification-inbox/notificationPresentation.js +34 -0
- package/src/pages/designer/_shared/ControlIconButton.vue +62 -0
- package/src/pages/designer/_shared/StyleControlSection.vue +59 -0
- package/src/pages/designer/_shared/cssEditorContext.js +19 -0
- package/src/pages/designer/backdrop-editor/BackdropControl.vue +57 -0
- package/src/pages/designer/backdrop-editor/DomBackdropEditor.vue +33 -0
- package/src/pages/designer/backdrop-editor/backdropModel.js +39 -0
- package/src/pages/designer/background-editor/BackgroundLayers.vue +210 -0
- package/src/pages/designer/background-editor/DomBackgroundEditor.vue +33 -0
- package/src/pages/designer/background-editor/backgroundModel.js +102 -0
- package/src/pages/designer/border-editor/BorderControl.vue +72 -0
- package/src/pages/designer/border-editor/DomBorderEditor.vue +34 -0
- package/src/pages/designer/border-editor/borderModel.js +21 -0
- package/src/pages/designer/color-swatch/DomColorSwatch.vue +95 -0
- package/src/pages/designer/frame/DomDesignerFrame.vue +16 -0
- package/src/pages/designer/frame/DomFrameLayoutEditor.vue +41 -0
- package/src/pages/designer/frame/frameLayout.js +93 -0
- package/src/pages/designer/layers/DomDesignerLayers.vue +11 -0
- package/src/pages/designer/shadow-editor/DomShadowEditor.vue +32 -0
- package/src/pages/designer/shadow-editor/ShadowLayers.vue +185 -0
- package/src/pages/designer/shadow-editor/shadowModel.js +73 -0
- package/src/pages/designer/stage/DesignerBounds.vue +31 -0
- package/src/pages/designer/stage/DomDesignerStage.vue +237 -0
- package/src/pages/designer/stage/SpacingGuides.vue +33 -0
- package/src/pages/designer/stage/resizeGeometry.js +15 -0
- package/src/pages/designer/stage/snapGeometry.js +69 -0
- package/src/pages/designer/stage/snapPipeline.js +17 -0
- package/src/pages/designer/stage/spacingGeometry.js +129 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +8 -3
- package/src/pages/forms/number-input/DomNumberInput.vue +39 -3
- package/src/pages/forms/range-input/DomRangeInput.vue +75 -23
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +91 -9
- package/src/pages/forms/toggle/DomToggle.vue +1 -1
- package/src/pages/lib/headless/base.js +10 -5
- package/src/pages/lib/headless/dialog.js +25 -6
- package/src/pages/lib/headless/drawer.js +49 -21
- package/src/pages/lib/vue/index.d.ts +1161 -14
- package/src/pages/lib/vue/index.js +43 -0
- package/src/pages/mobile/_shared/interactionMath.js +46 -0
- package/src/pages/mobile/_shared/useKeyboardViewport.js +91 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +102 -65
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +4 -2
- package/src/pages/mobile/bottom-sheet/DomBottomSheet.vue +221 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +6 -6
- package/src/pages/mobile/profile/DomAppProfile.vue +42 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +6 -6
- package/src/pages/mobile/shell/DomAppShell.vue +60 -9
- package/src/pages/mobile/stack/DomAppStack.vue +214 -55
- package/src/pages/mobile/swipe-actions/DomSwipeActions.vue +346 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +3 -3
- package/src/pages/visual/audio-visualizer/DomAudioVisualizer.vue +143 -0
- package/src/theme.css +13 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owns one local capture stream. A replacement is emitted before the previous
|
|
5
|
+
* microphone is released; cameras are released first for mobile camera switching.
|
|
6
|
+
* Pending permission requests are invalidated on stop/disposal and late streams
|
|
7
|
+
* are immediately released. No media is recorded, played back, or transmitted.
|
|
8
|
+
*
|
|
9
|
+
* @param {'audio'|'video'} kind The only media kind to request.
|
|
10
|
+
* @param {object} props Reactive deviceId, muted, and constraints preferences.
|
|
11
|
+
* @param {Function} emit Vue event emitter for stream, state, errors and models.
|
|
12
|
+
* @returns {object} Reactive capture state and explicit start/stop/select actions.
|
|
13
|
+
*/
|
|
14
|
+
export function useMediaInput(kind, props, emit) {
|
|
15
|
+
const stream = shallowRef(null);
|
|
16
|
+
const devices = shallowRef([]);
|
|
17
|
+
const deviceId = ref(props.deviceId || '');
|
|
18
|
+
const muted = ref(Boolean(props.muted));
|
|
19
|
+
const pending = ref(false);
|
|
20
|
+
const error = ref('');
|
|
21
|
+
const interrupted = ref(false);
|
|
22
|
+
let request = 0;
|
|
23
|
+
let enumeration = 0;
|
|
24
|
+
let disposed = false;
|
|
25
|
+
let mediaDevices;
|
|
26
|
+
let removeTrackListeners = () => {};
|
|
27
|
+
|
|
28
|
+
/** Describes local capture only; it does not imply a remote connection. */
|
|
29
|
+
const state = computed(() => pending.value ? 'requesting' : stream.value ? 'active' : error.value ? 'error' : 'idle');
|
|
30
|
+
/** Resolves a usable label without exposing a device identifier to the viewer. */
|
|
31
|
+
const deviceLabel = computed(() => devices.value.find(matchesDevice)?.label || stream.value?.getTracks()[0]?.label || (kind === 'audio' ? 'Microphone' : 'Camera'));
|
|
32
|
+
/** Tests the current committed input selection. */
|
|
33
|
+
function matchesDevice(device) { return device.deviceId === deviceId.value; }
|
|
34
|
+
/** Emits each meaningful local capture state transition. */
|
|
35
|
+
watch(state, (value) => emit('state', value));
|
|
36
|
+
/** Applies externally controlled device changes through the same capture path. */
|
|
37
|
+
watch(() => props.deviceId, (value) => { if ((value || '') !== deviceId.value) select(value || ''); });
|
|
38
|
+
/** Applies an externally controlled mute preference to current and future tracks. */
|
|
39
|
+
watch(() => props.muted, (value) => setMuted(Boolean(value)));
|
|
40
|
+
|
|
41
|
+
/** Returns the secure-context media API, attaching device discovery once. */
|
|
42
|
+
function api() {
|
|
43
|
+
if (globalThis.isSecureContext === false || !globalThis.navigator?.mediaDevices?.getUserMedia) {
|
|
44
|
+
throw new Error('Device access needs HTTPS or localhost and a browser that supports media capture.');
|
|
45
|
+
}
|
|
46
|
+
if (!mediaDevices) {
|
|
47
|
+
mediaDevices = globalThis.navigator.mediaDevices;
|
|
48
|
+
mediaDevices.addEventListener?.('devicechange', refresh);
|
|
49
|
+
}
|
|
50
|
+
return mediaDevices;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Refreshes permission-filtered inputs without requesting or opening a device. */
|
|
54
|
+
async function refresh() {
|
|
55
|
+
const version = ++enumeration;
|
|
56
|
+
try {
|
|
57
|
+
const list = await api().enumerateDevices();
|
|
58
|
+
if (disposed || version !== enumeration) return;
|
|
59
|
+
devices.value = list.filter(isInput).map(labelDevice);
|
|
60
|
+
} catch {
|
|
61
|
+
// Discovery can be unavailable while a tab is hidden; capture still works.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Keeps only selectable inputs of the requested kind. */
|
|
65
|
+
function isInput(device) { return device.kind === `${kind}input` && Boolean(device.deviceId); }
|
|
66
|
+
/** Supplies stable fallback labels until browser permission reveals real names. */
|
|
67
|
+
function labelDevice(device, index) {
|
|
68
|
+
return { deviceId: device.deviceId, label: device.label || `${kind === 'audio' ? 'Microphone' : 'Camera'} ${index + 1}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stops every track owned by the provided capture stream. */
|
|
72
|
+
function release(value) { for (const track of value?.getTracks() || []) track.stop(); }
|
|
73
|
+
/** Detaches listeners and releases the current stream, notifying consumers first. */
|
|
74
|
+
function clearStream() {
|
|
75
|
+
removeTrackListeners();
|
|
76
|
+
removeTrackListeners = () => {};
|
|
77
|
+
const previous = stream.value;
|
|
78
|
+
stream.value = null;
|
|
79
|
+
interrupted.value = false;
|
|
80
|
+
if (previous) emit('stream', null);
|
|
81
|
+
release(previous);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Converts browser capture errors into actionable, provider-independent copy. */
|
|
85
|
+
function report(cause) {
|
|
86
|
+
const input = kind === 'audio' ? 'microphone' : 'camera';
|
|
87
|
+
const messages = {
|
|
88
|
+
NotAllowedError: `Allow ${input} access in your browser settings, then try again.`,
|
|
89
|
+
SecurityError: `This page is not allowed to use your ${input}. Check the browser or embedded-page permissions.`,
|
|
90
|
+
NotFoundError: `No ${input} was found. Connect one and try again.`,
|
|
91
|
+
NotReadableError: `The ${input} is unavailable. Close other apps using it and try again.`,
|
|
92
|
+
OverconstrainedError: `The selected ${input} or its settings are unavailable. Choose another input or retry with the system default.`,
|
|
93
|
+
AbortError: `The ${input} could not start. Try again.`,
|
|
94
|
+
};
|
|
95
|
+
error.value = messages[cause?.name] || cause?.message || `Could not start the ${input}. Try again.`;
|
|
96
|
+
emit('error', { name: cause?.name || 'Error', message: error.value });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Requests capture after a user action. Resolves with the committed stream or
|
|
101
|
+
* null on failure/cancellation; consumers receive all changes via stream events.
|
|
102
|
+
* @param {string} selected Device identifier, or an empty string for the default.
|
|
103
|
+
* @returns {Promise<MediaStream|null>} Active local stream, if still requested.
|
|
104
|
+
*/
|
|
105
|
+
async function start(selected = deviceId.value) {
|
|
106
|
+
if (disposed) return null;
|
|
107
|
+
const version = ++request;
|
|
108
|
+
pending.value = true;
|
|
109
|
+
error.value = '';
|
|
110
|
+
try {
|
|
111
|
+
const capture = api();
|
|
112
|
+
if (kind === 'video') clearStream();
|
|
113
|
+
const constraints = { ...(props.constraints || {}) };
|
|
114
|
+
// Selection owns deviceId; caller constraints cannot silently override it.
|
|
115
|
+
delete constraints.deviceId;
|
|
116
|
+
if (selected) constraints.deviceId = { exact: selected };
|
|
117
|
+
const next = await capture.getUserMedia({ audio: false, video: false, [kind]: Object.keys(constraints).length ? constraints : true });
|
|
118
|
+
if (disposed || version !== request) { release(next); return null; }
|
|
119
|
+
const track = next.getTracks().find(isCaptureTrack);
|
|
120
|
+
if (!track || track.readyState === 'ended') {
|
|
121
|
+
release(next);
|
|
122
|
+
throw new Error('The input stopped before it was ready. Try again.');
|
|
123
|
+
}
|
|
124
|
+
removeTrackListeners();
|
|
125
|
+
const previous = stream.value;
|
|
126
|
+
stream.value = next;
|
|
127
|
+
track.enabled = !muted.value;
|
|
128
|
+
interrupted.value = track.muted;
|
|
129
|
+
track.addEventListener('ended', ended);
|
|
130
|
+
track.addEventListener('mute', trackMuted);
|
|
131
|
+
track.addEventListener('unmute', trackMuted);
|
|
132
|
+
/** Removes listeners before stopping a replaced or disposed track. */
|
|
133
|
+
removeTrackListeners = () => {
|
|
134
|
+
track.removeEventListener('ended', ended);
|
|
135
|
+
track.removeEventListener('mute', trackMuted);
|
|
136
|
+
track.removeEventListener('unmute', trackMuted);
|
|
137
|
+
};
|
|
138
|
+
deviceId.value = selected || track.getSettings().deviceId || '';
|
|
139
|
+
emit('update:deviceId', deviceId.value);
|
|
140
|
+
emit('stream', next);
|
|
141
|
+
release(previous);
|
|
142
|
+
await refresh();
|
|
143
|
+
return version === request ? next : null;
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
if (!disposed && version === request) {
|
|
146
|
+
report(cause);
|
|
147
|
+
// The last working microphone stays selected when replacement fails.
|
|
148
|
+
emit('update:deviceId', deviceId.value);
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
} finally {
|
|
152
|
+
if (!disposed && version === request) pending.value = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Matches the requested media track without depending on track ordering. */
|
|
156
|
+
function isCaptureTrack(track) { return track.kind === kind; }
|
|
157
|
+
/** Surfaces hardware interruptions without pretending that silence is speech. */
|
|
158
|
+
function trackMuted() { interrupted.value = Boolean(stream.value?.getTracks().find(isCaptureTrack)?.muted); }
|
|
159
|
+
/** Reports a removed/revoked device and leaves retry under the user's control. */
|
|
160
|
+
function ended() {
|
|
161
|
+
stop();
|
|
162
|
+
report(new Error(`The ${kind === 'audio' ? 'microphone' : 'camera'} disconnected or access ended. Reconnect it and try again.`));
|
|
163
|
+
refresh();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Cancels pending capture and releases all currently owned media tracks. */
|
|
167
|
+
function stop() {
|
|
168
|
+
request++;
|
|
169
|
+
pending.value = false;
|
|
170
|
+
error.value = '';
|
|
171
|
+
clearStream();
|
|
172
|
+
}
|
|
173
|
+
/** Selects a future input or replaces the active stream without recording it. */
|
|
174
|
+
async function select(value) {
|
|
175
|
+
if (disposed) return;
|
|
176
|
+
if (stream.value || pending.value) return start(value);
|
|
177
|
+
deviceId.value = value;
|
|
178
|
+
error.value = '';
|
|
179
|
+
emit('update:deviceId', value);
|
|
180
|
+
}
|
|
181
|
+
/** Changes capture track enabled state, retaining the device until Stop. */
|
|
182
|
+
function setMuted(value) {
|
|
183
|
+
muted.value = value;
|
|
184
|
+
for (const track of stream.value?.getTracks() || []) track.enabled = !value;
|
|
185
|
+
emit('update:muted', value);
|
|
186
|
+
}
|
|
187
|
+
/** Toggles the microphone without opening a second capture stream. */
|
|
188
|
+
function toggleMuted() { setMuted(!muted.value); }
|
|
189
|
+
/** Releases hardware on navigation, including pages entering the back-forward cache. */
|
|
190
|
+
globalThis.window?.addEventListener('pagehide', stop);
|
|
191
|
+
/** Invalidates async work and removes all device/page listeners on scope disposal. */
|
|
192
|
+
onScopeDispose(() => {
|
|
193
|
+
disposed = true;
|
|
194
|
+
enumeration++;
|
|
195
|
+
stop();
|
|
196
|
+
mediaDevices?.removeEventListener?.('devicechange', refresh);
|
|
197
|
+
globalThis.window?.removeEventListener('pagehide', stop);
|
|
198
|
+
});
|
|
199
|
+
return { stream, devices, deviceId, deviceLabel, muted, interrupted, pending, error, state, start, stop, select, refresh, setMuted, toggleMuted };
|
|
200
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, ref, useId } from 'vue';
|
|
3
|
+
import DomButton from '../button/DomButton.vue';
|
|
4
|
+
import DomIconButton from '../icon-button/DomIconButton.vue';
|
|
5
|
+
import DomPopover from '../popover/DomPopover.vue';
|
|
6
|
+
import DomAudioVisualizer from '../../visual/audio-visualizer/DomAudioVisualizer.vue';
|
|
7
|
+
import MediaDeviceSelect from '../_shared/media/MediaDeviceSelect.vue';
|
|
8
|
+
import { useMediaInput } from '../_shared/media/useMediaInput.js';
|
|
9
|
+
|
|
10
|
+
defineOptions({
|
|
11
|
+
__doc: {
|
|
12
|
+
nav: { section: 'ai' },
|
|
13
|
+
name: 'Audio controls',
|
|
14
|
+
tag: '<DomAudioControls>',
|
|
15
|
+
description: 'Click to talk becomes a compact microphone control: stop, live waveform, mute, and input selection. Ready for voice AI, calls, and collaboration.',
|
|
16
|
+
icon: 'M4 9v6M8 5v14M12 8v8M16 3v18M20 9v6',
|
|
17
|
+
events: [
|
|
18
|
+
{ name: 'stream', payload: '(stream: MediaStream | null)', description: 'Initial/replacement microphone stream, or null on stop. This component releases its owned tracks.' },
|
|
19
|
+
{ name: 'update:deviceId', payload: '(deviceId: string)', description: 'Committed input selection, with v-model:deviceId support.' },
|
|
20
|
+
{ name: 'update:muted', payload: '(muted: boolean)', description: 'Microphone mute state, with v-model:muted support.' },
|
|
21
|
+
{ name: 'state', payload: "(state: 'idle' | 'requesting' | 'active' | 'error')", description: 'Local capture lifecycle, independent of your remote connection.' },
|
|
22
|
+
{ name: 'error', payload: '({ name, message })', description: 'Permission, missing device, or capture failure.' },
|
|
23
|
+
],
|
|
24
|
+
keyboard: [
|
|
25
|
+
{ keys: 'Tab', action: 'Move between stop, mute, and microphone settings.' },
|
|
26
|
+
{ keys: 'Enter / Space', action: 'Activate the focused control.' },
|
|
27
|
+
{ keys: 'Escape', action: 'Dismiss microphone settings and return to the trigger.' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const props = defineProps({
|
|
32
|
+
label: { type: String, default: 'Click to talk', _edit: { description: 'Button label before microphone capture starts.' } },
|
|
33
|
+
deviceId: { type: String, default: '', _edit: { description: 'Input device ID, or empty for system default. Supports v-model:deviceId.' } },
|
|
34
|
+
muted: { type: Boolean, default: false, _edit: { description: 'Mute the capture track while keeping its device open. Supports v-model:muted.' } },
|
|
35
|
+
constraints: { type: Object, default: null, _edit: { description: 'Optional audio MediaTrackConstraints applied at start/selection. deviceId is controlled separately.' } },
|
|
36
|
+
});
|
|
37
|
+
const emit = defineEmits(['stream', 'update:deviceId', 'update:muted', 'state', 'error']);
|
|
38
|
+
const { stream, devices, deviceId, deviceLabel, muted, interrupted, pending, error, state, start, stop, select, refresh, toggleMuted } = useMediaInput('audio', props, emit);
|
|
39
|
+
const root = ref(null);
|
|
40
|
+
const settings = ref(null);
|
|
41
|
+
const settingsOpen = ref(false);
|
|
42
|
+
const triggerId = useId();
|
|
43
|
+
/** Announces lifecycle and mute changes without announcing each waveform sample. */
|
|
44
|
+
const status = computed(() => pending.value ? 'Waiting for microphone access' : interrupted.value ? 'Microphone interrupted' : !stream.value ? 'Microphone off' : muted.value ? 'Microphone muted' : `Microphone on: ${deviceLabel.value}`);
|
|
45
|
+
/** Starts capture and moves keyboard focus to the resulting control group. */
|
|
46
|
+
async function begin() {
|
|
47
|
+
const active = await start();
|
|
48
|
+
if (active) { await nextTick(); root.value?.querySelector('[data-stop]')?.focus(); }
|
|
49
|
+
}
|
|
50
|
+
/** Stops capture and restores focus to the click-to-talk button. */
|
|
51
|
+
async function end() {
|
|
52
|
+
stop();
|
|
53
|
+
settings.value?.close();
|
|
54
|
+
await nextTick();
|
|
55
|
+
root.value?.querySelector('[data-start]')?.focus();
|
|
56
|
+
}
|
|
57
|
+
/** Refreshes names after permission and whenever the settings panel opens. */
|
|
58
|
+
function openSettings() { settingsOpen.value = true; refresh(); }
|
|
59
|
+
/** Commits the chosen microphone then dismisses its settings panel on success. */
|
|
60
|
+
async function selectInput(value) { await select(value); if (!error.value) settings.value?.close(); }
|
|
61
|
+
defineExpose({ start, stop, stream, state });
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div ref="root" class="inline-flex max-w-full flex-col items-center gap-3">
|
|
66
|
+
<DomButton v-if="!stream && !pending" data-start size="lg" @click="begin">
|
|
67
|
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5ZM5 10v2a7 7 0 0 0 14 0v-2M12 19v3" /></svg>
|
|
68
|
+
{{ error ? 'Try again' : label }}
|
|
69
|
+
</DomButton>
|
|
70
|
+
<div v-else role="group" aria-label="Microphone controls" class="flex max-w-full items-center gap-1 rounded-full border border-border bg-secondary p-1.5 shadow-lg">
|
|
71
|
+
<DomIconButton data-stop label="Stop microphone" variant="danger" size="lg" class="bg-destructive/15" icon="M6 6l12 12M18 6 6 18" @click="end" />
|
|
72
|
+
<div class="flex h-12 w-32 min-w-12 items-center justify-center rounded-2xl bg-canvas px-2 sm:w-40">
|
|
73
|
+
<span v-if="pending" role="status" class="truncate text-xs text-muted-fg">Connecting…</span>
|
|
74
|
+
<DomAudioVisualizer v-else :stream="stream" :muted="muted || interrupted" class="w-full" :class="muted ? 'text-muted-fg/40' : 'text-canvas-fg'" />
|
|
75
|
+
</div>
|
|
76
|
+
<DomIconButton :label="muted ? 'Unmute microphone' : 'Mute microphone'" :aria-pressed="muted" :disabled="!stream" :active="muted" :variant="muted ? 'danger' : 'ghost'" size="lg" @click="toggleMuted">
|
|
77
|
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5ZM5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8" /><path v-if="muted" d="m3 3 18 18" /></svg>
|
|
78
|
+
</DomIconButton>
|
|
79
|
+
<DomIconButton :id="triggerId" label="Select microphone" :aria-expanded="settingsOpen" aria-haspopup="dialog" :disabled="pending" size="lg" icon="m6 9 6 6 6-6" />
|
|
80
|
+
<DomPopover ref="settings" :trigger-id="triggerId" position="bottom-end" :arrow="false" width="w-72 max-w-[calc(100vw-2rem)]" @open="openSettings" @close="settingsOpen = false">
|
|
81
|
+
<div role="dialog" aria-label="Microphone settings" class="space-y-3">
|
|
82
|
+
<p class="text-sm font-semibold text-canvas-fg">Audio input</p>
|
|
83
|
+
<MediaDeviceSelect :devices="devices" :device-id="deviceId" :disabled="pending" @select="selectInput" />
|
|
84
|
+
<p class="text-xs leading-relaxed text-muted-fg">Choose the microphone you want to use.</p>
|
|
85
|
+
</div>
|
|
86
|
+
</DomPopover>
|
|
87
|
+
</div>
|
|
88
|
+
<p role="status" class="sr-only">{{ status }}</p>
|
|
89
|
+
<p v-if="error" role="alert" class="max-w-sm text-center text-sm leading-relaxed text-destructive">{{ error }}</p>
|
|
90
|
+
</div>
|
|
91
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import DomButton from '../button/DomButton.vue';
|
|
4
|
+
import DomIconButton from '../icon-button/DomIconButton.vue';
|
|
5
|
+
import DomAudioVisualizer from '../../visual/audio-visualizer/DomAudioVisualizer.vue';
|
|
6
|
+
import MediaDeviceSelect from '../_shared/media/MediaDeviceSelect.vue';
|
|
7
|
+
import { useMediaInput } from '../_shared/media/useMediaInput.js';
|
|
8
|
+
|
|
9
|
+
defineOptions({
|
|
10
|
+
__doc: {
|
|
11
|
+
nav: { section: 'ai' },
|
|
12
|
+
name: 'Audio input',
|
|
13
|
+
tag: '<DomAudioInput>',
|
|
14
|
+
description: 'Microphone setup with device selection, a live input meter, mute, and explicit start/stop. Emits a local MediaStream for your meeting or AI application.',
|
|
15
|
+
icon: 'M12 15a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v7a3 3 0 0 0 3 3ZM5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8',
|
|
16
|
+
events: [
|
|
17
|
+
{ name: 'stream', payload: '(stream: MediaStream | null)', description: 'Emits the initial or replacement stream; null means capture stopped. This component owns and releases its tracks.' },
|
|
18
|
+
{ name: 'update:deviceId', payload: '(deviceId: string)', description: 'Committed microphone selection. Supports v-model:deviceId.' },
|
|
19
|
+
{ name: 'update:muted', payload: '(muted: boolean)', description: 'Track enabled state changed. Supports v-model:muted.' },
|
|
20
|
+
{ name: 'state', payload: "(state: 'idle' | 'requesting' | 'active' | 'error')", description: 'Local device lifecycle. Active does not imply a network connection.' },
|
|
21
|
+
{ name: 'error', payload: '({ name, message })', description: 'Actionable capture failure. A failed microphone switch keeps the previous stream.' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const props = defineProps({
|
|
26
|
+
deviceId: { type: String, default: '', _edit: { description: 'Microphone device ID, or empty for the system default. Supports v-model:deviceId.' } },
|
|
27
|
+
muted: { type: Boolean, default: false, _edit: { description: 'Mute current and future tracks without releasing the microphone. Supports v-model:muted.' } },
|
|
28
|
+
constraints: { type: Object, default: null, _edit: { description: 'Optional audio MediaTrackConstraints, applied at start or selection. deviceId is controlled separately.' } },
|
|
29
|
+
label: { type: String, default: 'Microphone', _edit: { description: 'Setup heading and device picker label.' } },
|
|
30
|
+
});
|
|
31
|
+
const emit = defineEmits(['stream', 'update:deviceId', 'update:muted', 'state', 'error']);
|
|
32
|
+
const { stream, devices, deviceId, muted, interrupted, pending, error, state, start, stop, select, refresh, toggleMuted } = useMediaInput('audio', props, emit);
|
|
33
|
+
/** Explains the true input state without confusing a quiet microphone with failure. */
|
|
34
|
+
const status = computed(() => pending.value ? 'Waiting for microphone access…' : interrupted.value ? 'Microphone interrupted' : !stream.value ? 'Microphone is off' : muted.value ? 'Microphone muted' : 'Speak to check your input');
|
|
35
|
+
defineExpose({ start, stop, stream, state });
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<section class="w-full min-w-0 space-y-5 rounded-2xl border border-border bg-canvas p-5 text-canvas-fg" :aria-label="`${label} setup`">
|
|
40
|
+
<div class="flex items-center justify-between gap-3">
|
|
41
|
+
<h3 class="text-sm font-semibold">{{ label }}</h3>
|
|
42
|
+
<span class="flex items-center gap-1.5 text-xs text-muted-fg"><span class="size-1.5 rounded-full" :class="stream && !muted && !interrupted ? 'bg-success' : 'bg-muted-fg/40'" />{{ stream ? 'On' : 'Off' }}</span>
|
|
43
|
+
</div>
|
|
44
|
+
<MediaDeviceSelect :devices="devices" :device-id="deviceId" :label="label" :disabled="pending" @select="select" @focusin="refresh" />
|
|
45
|
+
<div class="rounded-xl bg-secondary/60 px-4 py-3">
|
|
46
|
+
<DomAudioVisualizer :stream="stream" :muted="muted || interrupted" class="text-primary" />
|
|
47
|
+
<p role="status" class="mt-2 text-center text-xs text-muted-fg">{{ status }}</p>
|
|
48
|
+
</div>
|
|
49
|
+
<p v-if="error" role="alert" class="text-sm leading-relaxed text-destructive">{{ error }}</p>
|
|
50
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
51
|
+
<DomButton v-if="!stream && !pending" size="sm" @click="start()">{{ error ? 'Try microphone again' : 'Enable microphone' }}</DomButton>
|
|
52
|
+
<DomButton v-if="pending || stream" variant="secondary" size="sm" @click="stop">{{ pending ? 'Cancel' : 'Stop microphone' }}</DomButton>
|
|
53
|
+
<DomIconButton v-if="stream" :label="muted ? 'Unmute microphone' : 'Mute microphone'" :aria-pressed="muted" :active="muted" :variant="muted ? 'danger' : 'ghost'" @click="toggleMuted">
|
|
54
|
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5ZM5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8" /><path v-if="muted" d="m3 3 18 18" /></svg>
|
|
55
|
+
</DomIconButton>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, useAttrs } from 'vue';
|
|
3
|
+
import { buttonInteraction } from './buttonStyles.js';
|
|
3
4
|
|
|
4
5
|
defineOptions({
|
|
5
6
|
inheritAttrs: false,
|
|
@@ -35,6 +36,11 @@ const props = defineProps({
|
|
|
35
36
|
description: 'Five balanced sizes.',
|
|
36
37
|
},
|
|
37
38
|
},
|
|
39
|
+
pressed: {
|
|
40
|
+
type: Boolean,
|
|
41
|
+
default: false,
|
|
42
|
+
_edit: { description: 'Show native press feedback for an external activation, such as a keyboard shortcut. Does not represent toggle selection.' },
|
|
43
|
+
},
|
|
38
44
|
loading: {
|
|
39
45
|
type: Boolean,
|
|
40
46
|
default: false,
|
|
@@ -47,11 +53,11 @@ const props = defineProps({
|
|
|
47
53
|
},
|
|
48
54
|
});
|
|
49
55
|
const attrs = useAttrs();
|
|
50
|
-
const interaction = 'select-none active:bg-[linear-gradient(rgb(0_0_0/6%)_0_0)] active:scale-[0.97] active:shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] motion-reduce:active:scale-100';
|
|
51
56
|
const variants = {
|
|
52
57
|
primary: 'skin-action hover:opacity-90',
|
|
53
58
|
secondary: 'skin-control hover:brightness-[0.98]',
|
|
54
|
-
|
|
59
|
+
// Reserve the border so variant changes fade from transparent without a currentColor flash or width shift.
|
|
60
|
+
ghost: 'border border-transparent text-canvas-fg hover:bg-secondary hover:text-secondary-fg',
|
|
55
61
|
danger: 'skin-control text-destructive hover:brightness-[0.98]',
|
|
56
62
|
};
|
|
57
63
|
const sizes = {
|
|
@@ -63,7 +69,7 @@ const sizes = {
|
|
|
63
69
|
};
|
|
64
70
|
|
|
65
71
|
const classes = computed(() => [
|
|
66
|
-
|
|
72
|
+
buttonInteraction,
|
|
67
73
|
'inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-full font-medium tracking-tight transition',
|
|
68
74
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas',
|
|
69
75
|
variants[props.variant] || variants.primary,
|
|
@@ -122,7 +128,7 @@ function onClick(event) {
|
|
|
122
128
|
</script>
|
|
123
129
|
|
|
124
130
|
<template>
|
|
125
|
-
<component :is="as" v-bind="componentAttrs" :class="classes" @click="onClick">
|
|
131
|
+
<component :is="as" v-bind="componentAttrs" :class="classes" :data-pressed="pressed ? '' : undefined" @click="onClick">
|
|
126
132
|
<svg v-if="loading" class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
127
133
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" />
|
|
128
134
|
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pointer and programmatic press feedback for text and icon buttons.
|
|
3
|
+
* The pressed attribute is visual feedback, independent of toggle selection.
|
|
4
|
+
*/
|
|
5
|
+
export const buttonInteraction = 'select-none [&:is(:active,[data-pressed])]:bg-[linear-gradient(rgb(0_0_0/6%)_0_0)] [&:is(:active,[data-pressed])]:scale-[0.97] [&:is(:active,[data-pressed])]:shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] motion-reduce:[&:is(:active,[data-pressed])]:scale-100';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
3
|
+
import DomButton from '../button/DomButton.vue';
|
|
4
|
+
import MediaDeviceSelect from '../_shared/media/MediaDeviceSelect.vue';
|
|
5
|
+
import { useMediaInput } from '../_shared/media/useMediaInput.js';
|
|
6
|
+
|
|
7
|
+
defineOptions({
|
|
8
|
+
__doc: {
|
|
9
|
+
nav: { section: 'ai' },
|
|
10
|
+
name: 'Camera input',
|
|
11
|
+
tag: '<DomCameraInput>',
|
|
12
|
+
description: 'Select a webcam and check a private local preview before joining a call. Emits a video-only MediaStream and releases the camera when stopped.',
|
|
13
|
+
icon: 'M3 5h12v14H3zM15 10l6-4v12l-6-4',
|
|
14
|
+
events: [
|
|
15
|
+
{ name: 'stream', payload: '(stream: MediaStream | null)', description: 'Video-only local stream or null when released. Cameras are released before switching for mobile compatibility.' },
|
|
16
|
+
{ name: 'update:deviceId', payload: '(deviceId: string)', description: 'Committed camera selection. Supports v-model:deviceId.' },
|
|
17
|
+
{ name: 'state', payload: "(state: 'idle' | 'requesting' | 'active' | 'error')", description: 'Local camera capture state.' },
|
|
18
|
+
{ name: 'error', payload: '({ name, message })', description: 'Camera access or device failure.' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const props = defineProps({
|
|
23
|
+
deviceId: { type: String, default: '', _edit: { description: 'Camera device ID, or empty for system default. Supports v-model:deviceId.' } },
|
|
24
|
+
constraints: { type: Object, default: null, _edit: { description: 'Optional video MediaTrackConstraints such as facingMode or ideal width. Applied at start or selection.' } },
|
|
25
|
+
mirror: { type: Boolean, default: true, _edit: { description: 'Mirror only the local preview for a front-facing camera. The emitted stream is unchanged.' } },
|
|
26
|
+
label: { type: String, default: 'Camera', _edit: { description: 'Setup heading and camera picker label.' } },
|
|
27
|
+
});
|
|
28
|
+
const emit = defineEmits(['stream', 'update:deviceId', 'state', 'error']);
|
|
29
|
+
const { stream, devices, deviceId, deviceLabel, interrupted, pending, error, state, start, stop, select, refresh } = useMediaInput('video', props, emit);
|
|
30
|
+
const video = ref(null);
|
|
31
|
+
const previewBlocked = ref(false);
|
|
32
|
+
let playback = 0;
|
|
33
|
+
/** Describes capture independently of whether browser autoplay displayed it. */
|
|
34
|
+
const status = computed(() => pending.value ? 'Waiting for camera access…' : interrupted.value ? 'Camera interrupted' : stream.value ? 'Camera preview is on' : 'Camera is off');
|
|
35
|
+
/** Binds a MediaStream using srcObject; avoids URLs, recording, and audio playback. */
|
|
36
|
+
async function showPreview() {
|
|
37
|
+
const version = ++playback;
|
|
38
|
+
const element = video.value;
|
|
39
|
+
if (!element) return;
|
|
40
|
+
previewBlocked.value = false;
|
|
41
|
+
element.srcObject = stream.value;
|
|
42
|
+
if (!stream.value) return;
|
|
43
|
+
try { await element.play(); }
|
|
44
|
+
catch { if (version === playback) previewBlocked.value = true; }
|
|
45
|
+
}
|
|
46
|
+
/** Attaches each replacement stream after Vue creates its video element. */
|
|
47
|
+
watch([stream, video], showPreview, { flush: 'post' });
|
|
48
|
+
/** Clears media-element references after capture ownership releases its tracks. */
|
|
49
|
+
onBeforeUnmount(() => { playback++; if (video.value) video.value.srcObject = null; });
|
|
50
|
+
defineExpose({ start, stop, stream, state });
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<section class="w-full min-w-0 space-y-5 rounded-2xl border border-border bg-canvas p-5 text-canvas-fg" :aria-label="`${label} setup`">
|
|
55
|
+
<div class="flex items-center justify-between gap-3">
|
|
56
|
+
<h3 class="text-sm font-semibold">{{ label }}</h3>
|
|
57
|
+
<span class="flex items-center gap-1.5 text-xs text-muted-fg"><span class="size-1.5 rounded-full" :class="stream && !interrupted ? 'bg-success' : 'bg-muted-fg/40'" />{{ stream ? 'On' : 'Off' }}</span>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="relative aspect-video overflow-hidden rounded-xl border border-border bg-secondary">
|
|
60
|
+
<video v-if="stream" ref="video" autoplay muted playsinline :aria-label="`${deviceLabel} local preview`" class="h-full w-full object-cover" :class="{ '-scale-x-100': mirror }" />
|
|
61
|
+
<div v-if="!stream || previewBlocked || interrupted" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-secondary text-muted-fg">
|
|
62
|
+
<svg class="size-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5h12v14H3zM15 10l6-4v12l-6-4" /></svg>
|
|
63
|
+
<DomButton v-if="previewBlocked" variant="secondary" size="sm" @click="showPreview">Show camera preview</DomButton>
|
|
64
|
+
<p v-else class="text-sm">{{ pending ? 'Starting camera…' : interrupted ? 'Camera interrupted' : 'Your camera preview' }}</p>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<MediaDeviceSelect :devices="devices" :device-id="deviceId" :label="label" :disabled="pending" @select="select" @focusin="refresh" />
|
|
68
|
+
<p v-if="error" role="alert" class="text-sm leading-relaxed text-destructive">{{ error }}</p>
|
|
69
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
70
|
+
<DomButton v-if="!stream && !pending" size="sm" @click="start()">{{ error ? 'Try camera again' : 'Enable camera' }}</DomButton>
|
|
71
|
+
<DomButton v-else variant="secondary" size="sm" @click="stop">{{ pending ? 'Cancel' : 'Stop camera' }}</DomButton>
|
|
72
|
+
<p role="status" class="text-xs text-muted-fg">{{ status }}</p>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
</template>
|
|
@@ -67,7 +67,16 @@ const filtered = computed(() => {
|
|
|
67
67
|
.map((result) => result.command);
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
watch(() => props.modelValue,
|
|
70
|
+
watch(() => props.modelValue, syncPaletteOpenState);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Synchronizes visibility and focus for an existing palette or an initially open
|
|
74
|
+
* palette mounted through an async component after the user requests it.
|
|
75
|
+
*
|
|
76
|
+
* @param {boolean} open Whether the palette should be visible.
|
|
77
|
+
* @returns {Promise<void>} Resolves after the panel is ready for focus.
|
|
78
|
+
*/
|
|
79
|
+
async function syncPaletteOpenState(open) {
|
|
71
80
|
if (!open) {
|
|
72
81
|
if (!isRendered.value) return;
|
|
73
82
|
isVisible.value = false;
|
|
@@ -85,7 +94,7 @@ watch(() => props.modelValue, async (open) => {
|
|
|
85
94
|
isVisible.value = true;
|
|
86
95
|
input.value?.focus();
|
|
87
96
|
});
|
|
88
|
-
}
|
|
97
|
+
}
|
|
89
98
|
watch(query, resetActiveIndex);
|
|
90
99
|
watch(filtered, syncActiveIndex);
|
|
91
100
|
|
|
@@ -112,6 +121,7 @@ function syncActiveIndex(items) {
|
|
|
112
121
|
onMounted(() => {
|
|
113
122
|
teleportTarget.value = panel.value?.ownerDocument?.body || document.body;
|
|
114
123
|
isMounted.value = true;
|
|
124
|
+
if (props.modelValue) void syncPaletteOpenState(true);
|
|
115
125
|
window.addEventListener('keydown', onShortcut, { capture: true });
|
|
116
126
|
document.addEventListener('keydown', onShortcut, { capture: true });
|
|
117
127
|
window.addEventListener('keyup', onShortcutFallback, { capture: true });
|