anentrypoint-design 0.0.385 → 0.0.387
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/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// playCompletionCue — short two-tone audio cue (e.g. "agent turn finished"),
|
|
2
|
+
// ported from pi-web's useAudio. The voice surface otherwise covers voice-call
|
|
3
|
+
// UI (PTT/VAD/webcam/queue), not simple one-shot notification tones, so this is
|
|
4
|
+
// the missing piece rather than a duplicate. A single AudioContext is reused
|
|
5
|
+
// (module-scoped) so a browser autoplay-suspended context can be resumed
|
|
6
|
+
// instead of leaking a fresh context per call.
|
|
7
|
+
|
|
8
|
+
let _cueCtx = null;
|
|
9
|
+
function getCueCtx() {
|
|
10
|
+
if (_cueCtx && _cueCtx.state !== 'closed') return _cueCtx;
|
|
11
|
+
try { _cueCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch { return null; }
|
|
12
|
+
return _cueCtx;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function playCompletionCue() {
|
|
16
|
+
const ctx = getCueCtx();
|
|
17
|
+
if (!ctx) return;
|
|
18
|
+
const play = () => {
|
|
19
|
+
try {
|
|
20
|
+
const now = ctx.currentTime;
|
|
21
|
+
[523.25, 659.25].forEach((freq, i) => {
|
|
22
|
+
const osc = ctx.createOscillator();
|
|
23
|
+
const gain = ctx.createGain();
|
|
24
|
+
osc.connect(gain);
|
|
25
|
+
gain.connect(ctx.destination);
|
|
26
|
+
osc.type = 'sine';
|
|
27
|
+
osc.frequency.value = freq;
|
|
28
|
+
const t = now + i * 0.18;
|
|
29
|
+
gain.gain.setValueAtTime(0, t);
|
|
30
|
+
gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
|
|
31
|
+
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
|
|
32
|
+
osc.start(t);
|
|
33
|
+
osc.stop(t + 0.45);
|
|
34
|
+
});
|
|
35
|
+
} catch { /* swallow: AudioContext/oscillator unsupported, cue is best-effort */ }
|
|
36
|
+
};
|
|
37
|
+
if (ctx.state === 'suspended') { ctx.resume().then(play).catch(() => {}); return; }
|
|
38
|
+
play();
|
|
39
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Capture-side voice surfaces — what the local user speaks and shows through:
|
|
2
|
+
// the push-to-talk button, the voice-activity level meter with its threshold
|
|
3
|
+
// handle, and the webcam preview with resolution/fps pickers.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
export function PttButton({ state = 'idle', mode = 'ptt', onHoldStart, onHoldEnd, onClick, label = 'Hold to talk' } = {}) {
|
|
10
|
+
const active = state === 'live' || state === 'recording' || state === 'vad';
|
|
11
|
+
const start = (e) => { onHoldStart && onHoldStart(e); };
|
|
12
|
+
const end = (e) => { onHoldEnd && onHoldEnd(e); };
|
|
13
|
+
return h('button', {
|
|
14
|
+
type: 'button',
|
|
15
|
+
class: 'vx-ptt vx-ptt-' + state + ' vx-ptt-mode-' + mode,
|
|
16
|
+
'data-state': state,
|
|
17
|
+
'data-mode': mode,
|
|
18
|
+
'aria-pressed': active ? 'true' : 'false',
|
|
19
|
+
'aria-label': label,
|
|
20
|
+
onclick: onClick ? (e) => onClick(e) : null,
|
|
21
|
+
onpointerdown: (e) => { e.preventDefault(); start(e); },
|
|
22
|
+
onpointerup: (e) => { e.preventDefault(); end(e); },
|
|
23
|
+
onpointerleave: (e) => end(e),
|
|
24
|
+
oncontextmenu: (e) => e.preventDefault(),
|
|
25
|
+
ontouchstart: (e) => start(e),
|
|
26
|
+
ontouchend: (e) => { e.preventDefault(); end(e); }
|
|
27
|
+
},
|
|
28
|
+
h('span', { class: 'vx-ptt-glow', 'aria-hidden': 'true' }),
|
|
29
|
+
h('span', { class: 'vx-ptt-icon', 'aria-hidden': 'true' }, state === 'idle' ? Icon('mic') : h('span', { class: 'ds-dot ds-dot-on', 'aria-hidden': 'true' })),
|
|
30
|
+
h('span', { class: 'vx-ptt-label' }, label)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function VadMeter({ level = 0, threshold = 0.5, onThresholdChange } = {}) {
|
|
35
|
+
const lvl = Math.max(0, Math.min(1, Number(level) || 0));
|
|
36
|
+
const thr = Math.max(0, Math.min(1, Number(threshold) || 0));
|
|
37
|
+
const over = lvl >= thr;
|
|
38
|
+
return h('div', { class: 'vx-vad', role: 'group', 'aria-label': 'voice activity meter' },
|
|
39
|
+
h('div', { class: 'vx-vad-track' },
|
|
40
|
+
h('div', { class: 'vx-vad-fill' + (over ? ' vx-vad-fill-over' : ''), style: 'width:' + (lvl * 100).toFixed(1) + '%' }),
|
|
41
|
+
h('div', { class: 'vx-vad-marker', style: 'left:' + (thr * 100).toFixed(1) + '%', 'aria-hidden': 'true' }),
|
|
42
|
+
h('input', {
|
|
43
|
+
class: 'vx-vad-range',
|
|
44
|
+
type: 'range', min: '0', max: '1', step: '0.01',
|
|
45
|
+
value: String(thr),
|
|
46
|
+
'aria-label': 'VAD threshold',
|
|
47
|
+
oninput: onThresholdChange ? (e) => onThresholdChange(parseFloat(e.target.value)) : null
|
|
48
|
+
})
|
|
49
|
+
),
|
|
50
|
+
h('div', { class: 'vx-vad-readout' },
|
|
51
|
+
h('span', {}, 'lvl ' + Math.round(lvl * 100)),
|
|
52
|
+
h('span', {}, 'thr ' + Math.round(thr * 100))
|
|
53
|
+
)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function WebcamPreview({ videoStream = null, resolution = '640x480', fps = 30, enabled = true, resolutions = [], fpsOptions = [], onResolutionChange, onFpsChange, onToggle } = {}) {
|
|
58
|
+
const videoRef = (el) => {
|
|
59
|
+
if (!el) return;
|
|
60
|
+
if (el.srcObject !== videoStream) el.srcObject = videoStream || null;
|
|
61
|
+
};
|
|
62
|
+
const resOpts = (resolutions.length ? resolutions : [resolution]).map(r =>
|
|
63
|
+
h('option', { key: 'r-' + r, value: r, selected: r === resolution }, r));
|
|
64
|
+
const fpsList = fpsOptions.length ? fpsOptions : [fps];
|
|
65
|
+
const fpsOpts = fpsList.map(f =>
|
|
66
|
+
h('option', { key: 'f-' + f, value: String(f), selected: Number(f) === Number(fps) }, f + ' fps'));
|
|
67
|
+
return h('div', { class: 'vx-cam' + (enabled ? '' : ' vx-cam-off') },
|
|
68
|
+
h('div', { class: 'vx-cam-stage' },
|
|
69
|
+
enabled
|
|
70
|
+
? h('video', { class: 'vx-cam-video', ref: videoRef, autoplay: true, muted: true, playsinline: true })
|
|
71
|
+
: h('div', { class: 'vx-cam-placeholder' }, h('span', {}, Icon('camera')), h('span', {}, 'Camera off'))
|
|
72
|
+
),
|
|
73
|
+
h('div', { class: 'vx-cam-controls' },
|
|
74
|
+
h('select', {
|
|
75
|
+
class: 'vx-select', 'aria-label': 'resolution',
|
|
76
|
+
onchange: onResolutionChange ? (e) => onResolutionChange(e.target.value) : null
|
|
77
|
+
}, ...resOpts),
|
|
78
|
+
h('select', {
|
|
79
|
+
class: 'vx-select', 'aria-label': 'frame rate',
|
|
80
|
+
onchange: onFpsChange ? (e) => onFpsChange(Number(e.target.value)) : null
|
|
81
|
+
}, ...fpsOpts),
|
|
82
|
+
h('button', {
|
|
83
|
+
type: 'button',
|
|
84
|
+
class: 'vx-btn' + (enabled ? ' vx-btn-on' : ''),
|
|
85
|
+
'aria-pressed': enabled ? 'true' : 'false',
|
|
86
|
+
onclick: onToggle ? () => onToggle() : null
|
|
87
|
+
}, enabled ? 'Disable' : 'Enable')
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// In-call playback surfaces: the mic/deafen/camera/screen/settings/leave
|
|
2
|
+
// control toolbar, and the per-speaker audio queue strip with its
|
|
3
|
+
// pause/resume/skip transport.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
function fmtDur(s) {
|
|
10
|
+
s = Math.max(0, Math.round(Number(s) || 0));
|
|
11
|
+
if (s < 60) return s + 's';
|
|
12
|
+
const m = Math.floor(s / 60);
|
|
13
|
+
const r = s % 60;
|
|
14
|
+
return m + ':' + String(r).padStart(2, '0');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function VoiceControls({ muted = false, deafened = false, cameraOn = false, screenShareOn = false, onMic, onDeafen, onCamera, onScreenShare, onSettings, onLeave } = {}) {
|
|
18
|
+
const btn = (cls, on, label, glyph, handler) => h('button', {
|
|
19
|
+
type: 'button',
|
|
20
|
+
class: 'vx-vc-btn ' + cls + (on ? ' vx-vc-on' : '') + (handler ? '' : ' vx-vc-disabled'),
|
|
21
|
+
'aria-pressed': on ? 'true' : 'false',
|
|
22
|
+
'aria-label': label,
|
|
23
|
+
title: label,
|
|
24
|
+
disabled: handler ? null : true,
|
|
25
|
+
onclick: handler ? (e) => handler(e) : null
|
|
26
|
+
},
|
|
27
|
+
h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, glyph)
|
|
28
|
+
);
|
|
29
|
+
return h('div', { class: 'vx-vc', role: 'toolbar', 'aria-label': 'voice controls' },
|
|
30
|
+
btn('vx-vc-mic', !muted, muted ? 'Unmute' : 'Mute', Icon(muted ? 'mic-off' : 'mic'), onMic),
|
|
31
|
+
btn('vx-vc-deafen', !deafened, deafened ? 'Undeafen' : 'Deafen', Icon(deafened ? 'speaker-off' : 'speaker'), onDeafen),
|
|
32
|
+
btn('vx-vc-camera', cameraOn, cameraOn ? 'Stop camera' : 'Start camera', Icon('camera'), onCamera),
|
|
33
|
+
btn('vx-vc-screen', screenShareOn, screenShareOn ? 'Stop sharing' : 'Share screen', Icon('screen'), onScreenShare),
|
|
34
|
+
btn('vx-vc-settings', false, 'Voice settings', Icon('settings'), onSettings),
|
|
35
|
+
h('button', {
|
|
36
|
+
type: 'button', class: 'vx-vc-btn vx-vc-leave', 'aria-label': 'Leave voice', title: 'Leave voice',
|
|
37
|
+
onclick: onLeave ? (e) => onLeave(e) : null
|
|
38
|
+
}, h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, Icon('phone')))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function AudioQueue({ segments = [], currentSegmentId = null, paused = false, onReplay, onSkip, onResume, onPause } = {}) {
|
|
43
|
+
if (!segments || !segments.length) {
|
|
44
|
+
return h('div', { class: 'vx-queue vx-queue-empty' },
|
|
45
|
+
h('span', { class: 'vx-queue-empty-text' }, 'No audio queued'));
|
|
46
|
+
}
|
|
47
|
+
return h('div', { class: 'vx-queue', role: 'group', 'aria-label': 'audio queue' },
|
|
48
|
+
h('div', { class: 'vx-queue-ctrls' },
|
|
49
|
+
h('button', {
|
|
50
|
+
type: 'button', class: 'vx-queue-btn',
|
|
51
|
+
'aria-label': paused ? 'resume' : 'pause',
|
|
52
|
+
onclick: () => paused ? (onResume && onResume()) : (onPause && onPause())
|
|
53
|
+
}, paused ? Icon('play') : Icon('pause')),
|
|
54
|
+
h('button', {
|
|
55
|
+
type: 'button', class: 'vx-queue-btn',
|
|
56
|
+
'aria-label': 'skip', onclick: () => onSkip && onSkip()
|
|
57
|
+
}, Icon('skip-forward'))
|
|
58
|
+
),
|
|
59
|
+
h('div', { class: 'vx-queue-strip' },
|
|
60
|
+
...segments.map(s => h('button', {
|
|
61
|
+
key: 'q-' + s.id,
|
|
62
|
+
type: 'button',
|
|
63
|
+
class: 'vx-chip' + (s.id === currentSegmentId ? ' vx-chip-current' : '') + (s.isLive ? ' vx-chip-live' : ''),
|
|
64
|
+
'data-id': s.id,
|
|
65
|
+
onclick: () => onReplay && onReplay(s.id)
|
|
66
|
+
},
|
|
67
|
+
h('span', { class: 'vx-chip-dot', style: s.color ? 'background:' + s.color : null, 'aria-hidden': 'true' }),
|
|
68
|
+
h('span', { class: 'vx-chip-name' }, s.speaker || '—'),
|
|
69
|
+
s.isLive
|
|
70
|
+
? h('span', { class: 'vx-chip-tag' }, 'LIVE')
|
|
71
|
+
: h('span', { class: 'vx-chip-dur' }, fmtDur(s.duration))
|
|
72
|
+
))
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// VoiceSettingsModal — mode (PTT / VAD / live), input+output device pickers,
|
|
2
|
+
// VAD threshold, processing toggles, bitrate and master volume, plus the small
|
|
3
|
+
// section / device-select / toggle-row builders it composes from.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
function seg({ label, children, className = '' }) {
|
|
10
|
+
return h('div', { class: 'vx-section ' + className },
|
|
11
|
+
label != null ? h('div', { class: 'vx-section-label' }, label) : null,
|
|
12
|
+
...(Array.isArray(children) ? children : [children])
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function devSelect(value, devices, onChange, aria) {
|
|
17
|
+
return h('select', {
|
|
18
|
+
class: 'vx-select', 'aria-label': aria,
|
|
19
|
+
onchange: onChange ? (e) => onChange(e.target.value) : null
|
|
20
|
+
}, ...(devices || []).map(d =>
|
|
21
|
+
h('option', { key: 'd-' + d.value, value: d.value, selected: d.value === value }, d.label)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toggleRow(label, checked, onToggle) {
|
|
25
|
+
return h('label', { class: 'vx-toggle-row' },
|
|
26
|
+
h('span', {}, label),
|
|
27
|
+
h('input', {
|
|
28
|
+
type: 'checkbox', class: 'vx-toggle',
|
|
29
|
+
checked: checked ? true : null,
|
|
30
|
+
onchange: onToggle ? (e) => onToggle(e.target.checked) : null
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function VoiceSettingsModal({ open = false, mode = 'ptt', inputId, outputId, inputDevices = [], outputDevices = [], vadThreshold = 0.5, rnnoise = false, autoGain = false, forceTurn = false, bitrate = 64, volume, onChange, onSave, onCancel, onClose } = {}) {
|
|
36
|
+
if (!open) return null;
|
|
37
|
+
const patch = (p) => onChange && onChange(p);
|
|
38
|
+
const modes = ['ptt', 'vad', 'live'];
|
|
39
|
+
const vol = volume == null ? 1 : volume;
|
|
40
|
+
return h('div', {
|
|
41
|
+
class: 'vx-modal-backdrop',
|
|
42
|
+
onclick: (e) => { if (e.target === e.currentTarget) onClose && onClose(); },
|
|
43
|
+
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose && onClose(); } }
|
|
44
|
+
},
|
|
45
|
+
h('div', { class: 'vx-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Voice settings' },
|
|
46
|
+
h('div', { class: 'vx-modal-head' },
|
|
47
|
+
h('h2', { class: 'vx-modal-title' }, 'Voice settings'),
|
|
48
|
+
h('button', { type: 'button', class: 'vx-modal-x', 'aria-label': 'close', onclick: () => onClose && onClose() }, Icon('x'))
|
|
49
|
+
),
|
|
50
|
+
h('div', { class: 'vx-modal-body' },
|
|
51
|
+
seg({ label: 'Mode', children:
|
|
52
|
+
h('div', { class: 'vx-segmented', role: 'group', 'aria-label': 'mode' },
|
|
53
|
+
...modes.map(m => h('button', {
|
|
54
|
+
key: 'm-' + m, type: 'button',
|
|
55
|
+
class: 'vx-seg' + (m === mode ? ' vx-seg-on' : ''),
|
|
56
|
+
'aria-pressed': m === mode ? 'true' : 'false',
|
|
57
|
+
onclick: () => patch({ mode: m })
|
|
58
|
+
}, m.toUpperCase())))
|
|
59
|
+
}),
|
|
60
|
+
seg({ label: 'Input device', children: devSelect(inputId, inputDevices, (v) => patch({ inputId: v }), 'input device') }),
|
|
61
|
+
seg({ label: 'Output device', children: devSelect(outputId, outputDevices, (v) => patch({ outputId: v }), 'output device') }),
|
|
62
|
+
mode === 'vad' ? seg({ label: 'VAD threshold', children:
|
|
63
|
+
h('div', { class: 'vx-range-row' },
|
|
64
|
+
h('input', {
|
|
65
|
+
type: 'range', class: 'vx-range', min: '0', max: '1', step: '0.01',
|
|
66
|
+
value: String(vadThreshold), 'aria-label': 'VAD threshold',
|
|
67
|
+
oninput: (e) => patch({ vadThreshold: parseFloat(e.target.value) })
|
|
68
|
+
}),
|
|
69
|
+
h('span', { class: 'vx-range-val' }, Math.round((Number(vadThreshold) || 0) * 100) + '%')
|
|
70
|
+
)
|
|
71
|
+
}) : null,
|
|
72
|
+
seg({ label: 'Processing', children: [
|
|
73
|
+
toggleRow('RNNoise', rnnoise, (v) => patch({ rnnoise: v })),
|
|
74
|
+
toggleRow('Auto gain', autoGain, (v) => patch({ autoGain: v })),
|
|
75
|
+
toggleRow('Force TURN', forceTurn, (v) => patch({ forceTurn: v }))
|
|
76
|
+
]}),
|
|
77
|
+
seg({ label: 'Bitrate', children:
|
|
78
|
+
h('div', { class: 'vx-range-row' },
|
|
79
|
+
h('input', {
|
|
80
|
+
type: 'range', class: 'vx-range', min: '8', max: '256', step: '8',
|
|
81
|
+
value: String(bitrate), 'aria-label': 'bitrate',
|
|
82
|
+
oninput: (e) => patch({ bitrate: parseInt(e.target.value, 10) })
|
|
83
|
+
}),
|
|
84
|
+
h('span', { class: 'vx-range-val' }, (Number(bitrate) || 0) + ' kbps')
|
|
85
|
+
)
|
|
86
|
+
}),
|
|
87
|
+
seg({ label: 'Master volume', children:
|
|
88
|
+
h('div', { class: 'vx-range-row' },
|
|
89
|
+
h('input', {
|
|
90
|
+
type: 'range', class: 'vx-range', min: '0', max: '1', step: '0.01',
|
|
91
|
+
value: String(vol), 'aria-label': 'master volume',
|
|
92
|
+
oninput: (e) => patch({ volume: parseFloat(e.target.value) })
|
|
93
|
+
}),
|
|
94
|
+
h('span', { class: 'vx-range-val' }, Math.round(vol * 100) + '%')
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
),
|
|
98
|
+
h('div', { class: 'vx-modal-foot' },
|
|
99
|
+
h('button', { type: 'button', class: 'vx-btn', onclick: () => onCancel && onCancel() }, 'Cancel'),
|
|
100
|
+
h('button', { type: 'button', class: 'vx-btn vx-btn-primary', onclick: () => onSave && onSave() }, 'Save')
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
}
|
package/src/components/voice.js
CHANGED
|
@@ -1,295 +1,18 @@
|
|
|
1
1
|
// Voice surfaces — PTT, VAD meter, webcam preview, voice settings, audio queue.
|
|
2
2
|
// Pure factories returning webjsx vnodes. Class prefix: vx-*.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
try { _cueCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch { return null; }
|
|
20
|
-
return _cueCtx;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function playCompletionCue() {
|
|
24
|
-
const ctx = getCueCtx();
|
|
25
|
-
if (!ctx) return;
|
|
26
|
-
const play = () => {
|
|
27
|
-
try {
|
|
28
|
-
const now = ctx.currentTime;
|
|
29
|
-
[523.25, 659.25].forEach((freq, i) => {
|
|
30
|
-
const osc = ctx.createOscillator();
|
|
31
|
-
const gain = ctx.createGain();
|
|
32
|
-
osc.connect(gain);
|
|
33
|
-
gain.connect(ctx.destination);
|
|
34
|
-
osc.type = 'sine';
|
|
35
|
-
osc.frequency.value = freq;
|
|
36
|
-
const t = now + i * 0.18;
|
|
37
|
-
gain.gain.setValueAtTime(0, t);
|
|
38
|
-
gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
|
|
39
|
-
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
|
|
40
|
-
osc.start(t);
|
|
41
|
-
osc.stop(t + 0.45);
|
|
42
|
-
});
|
|
43
|
-
} catch { /* swallow: AudioContext/oscillator unsupported, cue is best-effort */ }
|
|
44
|
-
};
|
|
45
|
-
if (ctx.state === 'suspended') { ctx.resume().then(play).catch(() => {}); return; }
|
|
46
|
-
play();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function fmtDur(s) {
|
|
50
|
-
s = Math.max(0, Math.round(Number(s) || 0));
|
|
51
|
-
if (s < 60) return s + 's';
|
|
52
|
-
const m = Math.floor(s / 60);
|
|
53
|
-
const r = s % 60;
|
|
54
|
-
return m + ':' + String(r).padStart(2, '0');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function PttButton({ state = 'idle', mode = 'ptt', onHoldStart, onHoldEnd, onClick, label = 'Hold to talk' } = {}) {
|
|
58
|
-
const active = state === 'live' || state === 'recording' || state === 'vad';
|
|
59
|
-
const start = (e) => { onHoldStart && onHoldStart(e); };
|
|
60
|
-
const end = (e) => { onHoldEnd && onHoldEnd(e); };
|
|
61
|
-
return h('button', {
|
|
62
|
-
type: 'button',
|
|
63
|
-
class: 'vx-ptt vx-ptt-' + state + ' vx-ptt-mode-' + mode,
|
|
64
|
-
'data-state': state,
|
|
65
|
-
'data-mode': mode,
|
|
66
|
-
'aria-pressed': active ? 'true' : 'false',
|
|
67
|
-
'aria-label': label,
|
|
68
|
-
onclick: onClick ? (e) => onClick(e) : null,
|
|
69
|
-
onpointerdown: (e) => { e.preventDefault(); start(e); },
|
|
70
|
-
onpointerup: (e) => { e.preventDefault(); end(e); },
|
|
71
|
-
onpointerleave: (e) => end(e),
|
|
72
|
-
oncontextmenu: (e) => e.preventDefault(),
|
|
73
|
-
ontouchstart: (e) => start(e),
|
|
74
|
-
ontouchend: (e) => { e.preventDefault(); end(e); }
|
|
75
|
-
},
|
|
76
|
-
h('span', { class: 'vx-ptt-glow', 'aria-hidden': 'true' }),
|
|
77
|
-
h('span', { class: 'vx-ptt-icon', 'aria-hidden': 'true' }, state === 'idle' ? Icon('mic') : h('span', { class: 'ds-dot ds-dot-on', 'aria-hidden': 'true' })),
|
|
78
|
-
h('span', { class: 'vx-ptt-label' }, label)
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function VadMeter({ level = 0, threshold = 0.5, onThresholdChange } = {}) {
|
|
83
|
-
const lvl = Math.max(0, Math.min(1, Number(level) || 0));
|
|
84
|
-
const thr = Math.max(0, Math.min(1, Number(threshold) || 0));
|
|
85
|
-
const over = lvl >= thr;
|
|
86
|
-
return h('div', { class: 'vx-vad', role: 'group', 'aria-label': 'voice activity meter' },
|
|
87
|
-
h('div', { class: 'vx-vad-track' },
|
|
88
|
-
h('div', { class: 'vx-vad-fill' + (over ? ' vx-vad-fill-over' : ''), style: 'width:' + (lvl * 100).toFixed(1) + '%' }),
|
|
89
|
-
h('div', { class: 'vx-vad-marker', style: 'left:' + (thr * 100).toFixed(1) + '%', 'aria-hidden': 'true' }),
|
|
90
|
-
h('input', {
|
|
91
|
-
class: 'vx-vad-range',
|
|
92
|
-
type: 'range', min: '0', max: '1', step: '0.01',
|
|
93
|
-
value: String(thr),
|
|
94
|
-
'aria-label': 'VAD threshold',
|
|
95
|
-
oninput: onThresholdChange ? (e) => onThresholdChange(parseFloat(e.target.value)) : null
|
|
96
|
-
})
|
|
97
|
-
),
|
|
98
|
-
h('div', { class: 'vx-vad-readout' },
|
|
99
|
-
h('span', {}, 'lvl ' + Math.round(lvl * 100)),
|
|
100
|
-
h('span', {}, 'thr ' + Math.round(thr * 100))
|
|
101
|
-
)
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function WebcamPreview({ videoStream = null, resolution = '640x480', fps = 30, enabled = true, resolutions = [], fpsOptions = [], onResolutionChange, onFpsChange, onToggle } = {}) {
|
|
106
|
-
const videoRef = (el) => {
|
|
107
|
-
if (!el) return;
|
|
108
|
-
if (el.srcObject !== videoStream) el.srcObject = videoStream || null;
|
|
109
|
-
};
|
|
110
|
-
const resOpts = (resolutions.length ? resolutions : [resolution]).map(r =>
|
|
111
|
-
h('option', { key: 'r-' + r, value: r, selected: r === resolution }, r));
|
|
112
|
-
const fpsList = fpsOptions.length ? fpsOptions : [fps];
|
|
113
|
-
const fpsOpts = fpsList.map(f =>
|
|
114
|
-
h('option', { key: 'f-' + f, value: String(f), selected: Number(f) === Number(fps) }, f + ' fps'));
|
|
115
|
-
return h('div', { class: 'vx-cam' + (enabled ? '' : ' vx-cam-off') },
|
|
116
|
-
h('div', { class: 'vx-cam-stage' },
|
|
117
|
-
enabled
|
|
118
|
-
? h('video', { class: 'vx-cam-video', ref: videoRef, autoplay: true, muted: true, playsinline: true })
|
|
119
|
-
: h('div', { class: 'vx-cam-placeholder' }, h('span', {}, Icon('camera')), h('span', {}, 'Camera off'))
|
|
120
|
-
),
|
|
121
|
-
h('div', { class: 'vx-cam-controls' },
|
|
122
|
-
h('select', {
|
|
123
|
-
class: 'vx-select', 'aria-label': 'resolution',
|
|
124
|
-
onchange: onResolutionChange ? (e) => onResolutionChange(e.target.value) : null
|
|
125
|
-
}, ...resOpts),
|
|
126
|
-
h('select', {
|
|
127
|
-
class: 'vx-select', 'aria-label': 'frame rate',
|
|
128
|
-
onchange: onFpsChange ? (e) => onFpsChange(Number(e.target.value)) : null
|
|
129
|
-
}, ...fpsOpts),
|
|
130
|
-
h('button', {
|
|
131
|
-
type: 'button',
|
|
132
|
-
class: 'vx-btn' + (enabled ? ' vx-btn-on' : ''),
|
|
133
|
-
'aria-pressed': enabled ? 'true' : 'false',
|
|
134
|
-
onclick: onToggle ? () => onToggle() : null
|
|
135
|
-
}, enabled ? 'Disable' : 'Enable')
|
|
136
|
-
)
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function seg({ label, children, className = '' }) {
|
|
141
|
-
return h('div', { class: 'vx-section ' + className },
|
|
142
|
-
label != null ? h('div', { class: 'vx-section-label' }, label) : null,
|
|
143
|
-
...(Array.isArray(children) ? children : [children])
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function devSelect(value, devices, onChange, aria) {
|
|
148
|
-
return h('select', {
|
|
149
|
-
class: 'vx-select', 'aria-label': aria,
|
|
150
|
-
onchange: onChange ? (e) => onChange(e.target.value) : null
|
|
151
|
-
}, ...(devices || []).map(d =>
|
|
152
|
-
h('option', { key: 'd-' + d.value, value: d.value, selected: d.value === value }, d.label)));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function toggleRow(label, checked, onToggle) {
|
|
156
|
-
return h('label', { class: 'vx-toggle-row' },
|
|
157
|
-
h('span', {}, label),
|
|
158
|
-
h('input', {
|
|
159
|
-
type: 'checkbox', class: 'vx-toggle',
|
|
160
|
-
checked: checked ? true : null,
|
|
161
|
-
onchange: onToggle ? (e) => onToggle(e.target.checked) : null
|
|
162
|
-
})
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function VoiceSettingsModal({ open = false, mode = 'ptt', inputId, outputId, inputDevices = [], outputDevices = [], vadThreshold = 0.5, rnnoise = false, autoGain = false, forceTurn = false, bitrate = 64, volume, onChange, onSave, onCancel, onClose } = {}) {
|
|
167
|
-
if (!open) return null;
|
|
168
|
-
const patch = (p) => onChange && onChange(p);
|
|
169
|
-
const modes = ['ptt', 'vad', 'live'];
|
|
170
|
-
const vol = volume == null ? 1 : volume;
|
|
171
|
-
return h('div', {
|
|
172
|
-
class: 'vx-modal-backdrop',
|
|
173
|
-
onclick: (e) => { if (e.target === e.currentTarget) onClose && onClose(); },
|
|
174
|
-
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); onClose && onClose(); } }
|
|
175
|
-
},
|
|
176
|
-
h('div', { class: 'vx-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Voice settings' },
|
|
177
|
-
h('div', { class: 'vx-modal-head' },
|
|
178
|
-
h('h2', { class: 'vx-modal-title' }, 'Voice settings'),
|
|
179
|
-
h('button', { type: 'button', class: 'vx-modal-x', 'aria-label': 'close', onclick: () => onClose && onClose() }, Icon('x'))
|
|
180
|
-
),
|
|
181
|
-
h('div', { class: 'vx-modal-body' },
|
|
182
|
-
seg({ label: 'Mode', children:
|
|
183
|
-
h('div', { class: 'vx-segmented', role: 'group', 'aria-label': 'mode' },
|
|
184
|
-
...modes.map(m => h('button', {
|
|
185
|
-
key: 'm-' + m, type: 'button',
|
|
186
|
-
class: 'vx-seg' + (m === mode ? ' vx-seg-on' : ''),
|
|
187
|
-
'aria-pressed': m === mode ? 'true' : 'false',
|
|
188
|
-
onclick: () => patch({ mode: m })
|
|
189
|
-
}, m.toUpperCase())))
|
|
190
|
-
}),
|
|
191
|
-
seg({ label: 'Input device', children: devSelect(inputId, inputDevices, (v) => patch({ inputId: v }), 'input device') }),
|
|
192
|
-
seg({ label: 'Output device', children: devSelect(outputId, outputDevices, (v) => patch({ outputId: v }), 'output device') }),
|
|
193
|
-
mode === 'vad' ? seg({ label: 'VAD threshold', children:
|
|
194
|
-
h('div', { class: 'vx-range-row' },
|
|
195
|
-
h('input', {
|
|
196
|
-
type: 'range', class: 'vx-range', min: '0', max: '1', step: '0.01',
|
|
197
|
-
value: String(vadThreshold), 'aria-label': 'VAD threshold',
|
|
198
|
-
oninput: (e) => patch({ vadThreshold: parseFloat(e.target.value) })
|
|
199
|
-
}),
|
|
200
|
-
h('span', { class: 'vx-range-val' }, Math.round((Number(vadThreshold) || 0) * 100) + '%')
|
|
201
|
-
)
|
|
202
|
-
}) : null,
|
|
203
|
-
seg({ label: 'Processing', children: [
|
|
204
|
-
toggleRow('RNNoise', rnnoise, (v) => patch({ rnnoise: v })),
|
|
205
|
-
toggleRow('Auto gain', autoGain, (v) => patch({ autoGain: v })),
|
|
206
|
-
toggleRow('Force TURN', forceTurn, (v) => patch({ forceTurn: v }))
|
|
207
|
-
]}),
|
|
208
|
-
seg({ label: 'Bitrate', children:
|
|
209
|
-
h('div', { class: 'vx-range-row' },
|
|
210
|
-
h('input', {
|
|
211
|
-
type: 'range', class: 'vx-range', min: '8', max: '256', step: '8',
|
|
212
|
-
value: String(bitrate), 'aria-label': 'bitrate',
|
|
213
|
-
oninput: (e) => patch({ bitrate: parseInt(e.target.value, 10) })
|
|
214
|
-
}),
|
|
215
|
-
h('span', { class: 'vx-range-val' }, (Number(bitrate) || 0) + ' kbps')
|
|
216
|
-
)
|
|
217
|
-
}),
|
|
218
|
-
seg({ label: 'Master volume', children:
|
|
219
|
-
h('div', { class: 'vx-range-row' },
|
|
220
|
-
h('input', {
|
|
221
|
-
type: 'range', class: 'vx-range', min: '0', max: '1', step: '0.01',
|
|
222
|
-
value: String(vol), 'aria-label': 'master volume',
|
|
223
|
-
oninput: (e) => patch({ volume: parseFloat(e.target.value) })
|
|
224
|
-
}),
|
|
225
|
-
h('span', { class: 'vx-range-val' }, Math.round(vol * 100) + '%')
|
|
226
|
-
)
|
|
227
|
-
})
|
|
228
|
-
),
|
|
229
|
-
h('div', { class: 'vx-modal-foot' },
|
|
230
|
-
h('button', { type: 'button', class: 'vx-btn', onclick: () => onCancel && onCancel() }, 'Cancel'),
|
|
231
|
-
h('button', { type: 'button', class: 'vx-btn vx-btn-primary', onclick: () => onSave && onSave() }, 'Save')
|
|
232
|
-
)
|
|
233
|
-
)
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function VoiceControls({ muted = false, deafened = false, cameraOn = false, screenShareOn = false, onMic, onDeafen, onCamera, onScreenShare, onSettings, onLeave } = {}) {
|
|
238
|
-
const btn = (cls, on, label, glyph, handler) => h('button', {
|
|
239
|
-
type: 'button',
|
|
240
|
-
class: 'vx-vc-btn ' + cls + (on ? ' vx-vc-on' : '') + (handler ? '' : ' vx-vc-disabled'),
|
|
241
|
-
'aria-pressed': on ? 'true' : 'false',
|
|
242
|
-
'aria-label': label,
|
|
243
|
-
title: label,
|
|
244
|
-
disabled: handler ? null : true,
|
|
245
|
-
onclick: handler ? (e) => handler(e) : null
|
|
246
|
-
},
|
|
247
|
-
h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, glyph)
|
|
248
|
-
);
|
|
249
|
-
return h('div', { class: 'vx-vc', role: 'toolbar', 'aria-label': 'voice controls' },
|
|
250
|
-
btn('vx-vc-mic', !muted, muted ? 'Unmute' : 'Mute', Icon(muted ? 'mic-off' : 'mic'), onMic),
|
|
251
|
-
btn('vx-vc-deafen', !deafened, deafened ? 'Undeafen' : 'Deafen', Icon(deafened ? 'speaker-off' : 'speaker'), onDeafen),
|
|
252
|
-
btn('vx-vc-camera', cameraOn, cameraOn ? 'Stop camera' : 'Start camera', Icon('camera'), onCamera),
|
|
253
|
-
btn('vx-vc-screen', screenShareOn, screenShareOn ? 'Stop sharing' : 'Share screen', Icon('screen'), onScreenShare),
|
|
254
|
-
btn('vx-vc-settings', false, 'Voice settings', Icon('settings'), onSettings),
|
|
255
|
-
h('button', {
|
|
256
|
-
type: 'button', class: 'vx-vc-btn vx-vc-leave', 'aria-label': 'Leave voice', title: 'Leave voice',
|
|
257
|
-
onclick: onLeave ? (e) => onLeave(e) : null
|
|
258
|
-
}, h('span', { class: 'vx-vc-glyph', 'aria-hidden': 'true' }, Icon('phone')))
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export function AudioQueue({ segments = [], currentSegmentId = null, paused = false, onReplay, onSkip, onResume, onPause } = {}) {
|
|
263
|
-
if (!segments || !segments.length) {
|
|
264
|
-
return h('div', { class: 'vx-queue vx-queue-empty' },
|
|
265
|
-
h('span', { class: 'vx-queue-empty-text' }, 'No audio queued'));
|
|
266
|
-
}
|
|
267
|
-
return h('div', { class: 'vx-queue', role: 'group', 'aria-label': 'audio queue' },
|
|
268
|
-
h('div', { class: 'vx-queue-ctrls' },
|
|
269
|
-
h('button', {
|
|
270
|
-
type: 'button', class: 'vx-queue-btn',
|
|
271
|
-
'aria-label': paused ? 'resume' : 'pause',
|
|
272
|
-
onclick: () => paused ? (onResume && onResume()) : (onPause && onPause())
|
|
273
|
-
}, paused ? Icon('play') : Icon('pause')),
|
|
274
|
-
h('button', {
|
|
275
|
-
type: 'button', class: 'vx-queue-btn',
|
|
276
|
-
'aria-label': 'skip', onclick: () => onSkip && onSkip()
|
|
277
|
-
}, Icon('skip-forward'))
|
|
278
|
-
),
|
|
279
|
-
h('div', { class: 'vx-queue-strip' },
|
|
280
|
-
...segments.map(s => h('button', {
|
|
281
|
-
key: 'q-' + s.id,
|
|
282
|
-
type: 'button',
|
|
283
|
-
class: 'vx-chip' + (s.id === currentSegmentId ? ' vx-chip-current' : '') + (s.isLive ? ' vx-chip-live' : ''),
|
|
284
|
-
'data-id': s.id,
|
|
285
|
-
onclick: () => onReplay && onReplay(s.id)
|
|
286
|
-
},
|
|
287
|
-
h('span', { class: 'vx-chip-dot', style: s.color ? 'background:' + s.color : null, 'aria-hidden': 'true' }),
|
|
288
|
-
h('span', { class: 'vx-chip-name' }, s.speaker || '—'),
|
|
289
|
-
s.isLive
|
|
290
|
-
? h('span', { class: 'vx-chip-tag' }, 'LIVE')
|
|
291
|
-
: h('span', { class: 'vx-chip-dur' }, fmtDur(s.duration))
|
|
292
|
-
))
|
|
293
|
-
)
|
|
294
|
-
);
|
|
295
|
-
}
|
|
3
|
+
//
|
|
4
|
+
// This module is a barrel: every component lives in a single-responsibility
|
|
5
|
+
// submodule under ./voice/, and the public export surface here is unchanged
|
|
6
|
+
// — no consumer import needs to move.
|
|
7
|
+
|
|
8
|
+
import { playCompletionCue } from './voice/audio-cue.js';
|
|
9
|
+
import { PttButton, VadMeter, WebcamPreview } from './voice/capture.js';
|
|
10
|
+
import { VoiceSettingsModal } from './voice/settings-modal.js';
|
|
11
|
+
import { VoiceControls, AudioQueue } from './voice/playback.js';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
playCompletionCue,
|
|
15
|
+
PttButton, VadMeter, WebcamPreview,
|
|
16
|
+
VoiceSettingsModal,
|
|
17
|
+
VoiceControls, AudioQueue,
|
|
18
|
+
};
|