@joewinke/jatui 0.22.0 → 0.24.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/CLAUDE.md +72 -2
- package/dist/actions/reveal.d.ts +37 -0
- package/dist/actions/reveal.js +42 -0
- package/dist/components/AudioRecorder.svelte +529 -0
- package/dist/components/AudioRecorder.svelte.d.ts +30 -0
- package/dist/components/BulkActionBar.svelte +40 -0
- package/dist/components/BulkActionBar.svelte.d.ts +9 -0
- package/dist/components/ConfirmDialog.svelte +39 -35
- package/dist/components/CopyButton.svelte +5 -2
- package/dist/components/FloatingActionBar.svelte +55 -1
- package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
- package/dist/components/IconSelector.svelte +417 -0
- package/dist/components/IconSelector.svelte.d.ts +55 -0
- package/dist/components/InfiniteGrid.svelte +305 -0
- package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
- package/dist/components/InfiniteSearchGrid.svelte +131 -0
- package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
- package/dist/components/InputDialog.svelte +49 -38
- package/dist/components/KeyboardShortcutsOverlay.svelte +94 -3
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +61 -16
- package/dist/components/MediaCaptureIcon.svelte +23 -0
- package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
- package/dist/components/ThumbnailGrid.svelte +137 -0
- package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
- package/dist/components/batch/BatchJobTable.svelte +549 -0
- package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
- package/dist/components/batch/QuotaMeter.svelte +78 -0
- package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
- package/dist/components/batch/batchQueue.d.ts +56 -0
- package/dist/components/batch/batchQueue.js +81 -0
- package/dist/components/batch/elapsedEstimate.d.ts +24 -0
- package/dist/components/batch/elapsedEstimate.js +25 -0
- package/dist/components/batch/pollOperation.d.ts +45 -0
- package/dist/components/batch/pollOperation.js +80 -0
- package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
- package/dist/components/batch/useElapsedEstimate.js +38 -0
- package/dist/components/bulkActionBarKeys.d.ts +40 -0
- package/dist/components/bulkActionBarKeys.js +38 -0
- package/dist/components/infiniteGridLoader.d.ts +70 -0
- package/dist/components/infiniteGridLoader.js +141 -0
- package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
- package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
- package/dist/components/linked-columns/types.d.ts +9 -0
- package/dist/components/linked-columns/types.js +1 -0
- package/dist/components/replay/ChapterTimeline.svelte +43 -25
- package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
- package/dist/components/table/columnResize.d.ts +38 -0
- package/dist/components/table/columnResize.js +117 -0
- package/dist/components/table/index.d.ts +2 -0
- package/dist/components/table/index.js +1 -0
- package/dist/components/toast/ToastItem.svelte +47 -2
- package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
- package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
- package/dist/components/variant/variantCombinations.d.ts +75 -0
- package/dist/components/variant/variantCombinations.js +100 -0
- package/dist/index.d.ts +34 -3
- package/dist/index.js +30 -1
- package/dist/stores/toast.d.ts +8 -0
- package/dist/stores/toast.js +2 -0
- package/dist/styles/motion.css +100 -0
- package/dist/utils/audioRecorder.d.ts +28 -0
- package/dist/utils/audioRecorder.js +58 -0
- package/dist/utils/audioRecorderCore.d.ts +132 -0
- package/dist/utils/audioRecorderCore.js +447 -0
- package/dist/utils/clipboard.d.ts +25 -0
- package/dist/utils/clipboard.js +59 -0
- package/dist/utils/dateFormatters.d.ts +11 -2
- package/dist/utils/dateFormatters.js +22 -2
- package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
- package/dist/utils/keyboardShortcutRegistry.js +365 -0
- package/package.json +1 -1
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
// The capture pipeline lives in the headless factory (jatui-is2ow.8) —
|
|
3
|
+
// this component is its markup. Re-exported here so consumers importing
|
|
4
|
+
// the type from the component keep working.
|
|
5
|
+
export type { AudioRecordingResult } from '../utils/audioRecorderCore'
|
|
6
|
+
|
|
7
|
+
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
8
|
+
export function formatDuration(totalSeconds: number): string {
|
|
9
|
+
const mins = Math.floor(totalSeconds / 60)
|
|
10
|
+
const secs = Math.floor(totalSeconds % 60)
|
|
11
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
|
|
15
|
+
export function formatFileSize(bytes: number): string {
|
|
16
|
+
if (bytes <= 0) return '0 B'
|
|
17
|
+
const k = 1024
|
|
18
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
19
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
|
20
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script lang="ts">
|
|
25
|
+
/**
|
|
26
|
+
* AudioRecorder — voice-memo recorder with live waveform, ported from
|
|
27
|
+
* chimaro's chat/AudioRecorder.svelte (jatui-m4uz3.5).
|
|
28
|
+
*
|
|
29
|
+
* Completes jatui's audio trio alongside AudioWaveform (playback) and
|
|
30
|
+
* SpeechForm (voice-to-text input): this one is the CAPTURE surface.
|
|
31
|
+
*
|
|
32
|
+
* Ported as a host-agnostic primitive — everything chimaro-specific is
|
|
33
|
+
* decoupled into emitted callbacks:
|
|
34
|
+
* • no promptStore — the custom-prompt textarea was host state, not
|
|
35
|
+
* recorder state, and lives with the host;
|
|
36
|
+
* • no debugConfig — console chatter replaced by callback props;
|
|
37
|
+
* • no supabase template loading / analysis panel — hosts that want
|
|
38
|
+
* post-recording transcription or analysis consume `onRecordStop`'s
|
|
39
|
+
* blob payload and run their own pipeline (MardukOS Scribe does).
|
|
40
|
+
*
|
|
41
|
+
* What it owns: markup only — the theme-aware canvas waveform + level
|
|
42
|
+
* meter, phase badges, and size/duration readouts. The capture pipeline
|
|
43
|
+
* itself (permission requests, MIME negotiation, the
|
|
44
|
+
* record/pause/resume/stop/cancel state machine, timing) lives in the
|
|
45
|
+
* headless createAudioRecorder() factory (jatui-is2ow.8) so hosts can
|
|
46
|
+
* drive the same pipeline from their own UI.
|
|
47
|
+
*/
|
|
48
|
+
import { onDestroy, onMount } from 'svelte'
|
|
49
|
+
import { createAudioRecorder } from '../utils/audioRecorder'
|
|
50
|
+
import { getDaisyUIColor, watchThemeChanges } from '../utils/daisyuiColors'
|
|
51
|
+
import MediaCaptureIcon from './MediaCaptureIcon.svelte'
|
|
52
|
+
|
|
53
|
+
interface Props {
|
|
54
|
+
/** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
|
|
55
|
+
variant?: 'small' | 'large'
|
|
56
|
+
/** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
|
|
57
|
+
maxDurationMs?: number
|
|
58
|
+
disabled?: boolean
|
|
59
|
+
/** Fired once the mic stream is live and recording has started. */
|
|
60
|
+
onRecordStart?: () => void
|
|
61
|
+
onRecordPause?: () => void
|
|
62
|
+
onRecordResume?: () => void
|
|
63
|
+
/** Fired with the finished recording. May return a Promise — the
|
|
64
|
+
* component stays in "processing" until it settles, so hosts can
|
|
65
|
+
* upload/transcribe without the user re-triggering a recording. */
|
|
66
|
+
onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>
|
|
67
|
+
/** Fired when the user cancels — no blob is produced. */
|
|
68
|
+
onRecordCancel?: () => void
|
|
69
|
+
/** Fired on recorder errors (permission denied, no mic, start failure). */
|
|
70
|
+
onError?: (message: string) => void
|
|
71
|
+
class?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let {
|
|
75
|
+
variant = 'small',
|
|
76
|
+
maxDurationMs = 0,
|
|
77
|
+
disabled = false,
|
|
78
|
+
onRecordStart,
|
|
79
|
+
onRecordPause,
|
|
80
|
+
onRecordResume,
|
|
81
|
+
onRecordStop,
|
|
82
|
+
onRecordCancel,
|
|
83
|
+
onError,
|
|
84
|
+
class: className = ''
|
|
85
|
+
}: Props = $props()
|
|
86
|
+
|
|
87
|
+
type Phase = 'idle' | 'starting' | 'recording' | 'processing' | 'done'
|
|
88
|
+
|
|
89
|
+
let permissionDenied = $state(false)
|
|
90
|
+
|
|
91
|
+
// Headless capture core — all media handling, timing and state live there.
|
|
92
|
+
const rec = createAudioRecorder({
|
|
93
|
+
mode: 'toggle',
|
|
94
|
+
maxDurationMs,
|
|
95
|
+
onStart: onRecordStart,
|
|
96
|
+
onPause: onRecordPause,
|
|
97
|
+
onResume: onRecordResume,
|
|
98
|
+
onStop: onRecordStop,
|
|
99
|
+
onCancel: onRecordCancel,
|
|
100
|
+
onError: (err) => {
|
|
101
|
+
if (err.kind === 'denied') permissionDenied = true
|
|
102
|
+
onError?.(err.message)
|
|
103
|
+
},
|
|
104
|
+
// Canvas visualization — driven by the core's analyser frames.
|
|
105
|
+
onFrame: ({ frequency }) => {
|
|
106
|
+
if (!canvas) return
|
|
107
|
+
if (!canvasCtx) canvasCtx = canvas.getContext('2d')
|
|
108
|
+
sizeCanvasIfNeeded()
|
|
109
|
+
if (frequency.some((v) => v > 0)) drawWaveform(frequency)
|
|
110
|
+
else {
|
|
111
|
+
clearCanvas()
|
|
112
|
+
drawIdleDots()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const phase = $derived<Phase>(rec.phase)
|
|
118
|
+
const paused = $derived(rec.paused)
|
|
119
|
+
const elapsedSec = $derived(Math.floor(rec.elapsedMs / 1000))
|
|
120
|
+
const sizeBytes = $derived(rec.sizeBytes)
|
|
121
|
+
const audioLevel = $derived(rec.level)
|
|
122
|
+
const errorMessage = $derived(rec.error?.message ?? '')
|
|
123
|
+
const supported = $derived(rec.supported)
|
|
124
|
+
|
|
125
|
+
// Canvas
|
|
126
|
+
let canvas = $state<HTMLCanvasElement | null>(null)
|
|
127
|
+
let canvasCtx: CanvasRenderingContext2D | null = null
|
|
128
|
+
let canvasColors = { background: '', centerLine: '', primary: '', secondary: '', dots: '' }
|
|
129
|
+
|
|
130
|
+
function resolveColor(name: string, fallback: string): string {
|
|
131
|
+
if (typeof window === 'undefined') return fallback
|
|
132
|
+
try {
|
|
133
|
+
const c = getDaisyUIColor(name)
|
|
134
|
+
return c && c !== 'undefined' && c !== '' ? c : fallback
|
|
135
|
+
} catch {
|
|
136
|
+
return fallback
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function updateCanvasColors() {
|
|
141
|
+
canvasColors = {
|
|
142
|
+
background: resolveColor('base-200', '#1f2937'),
|
|
143
|
+
centerLine: resolveColor('base-300', '#374151'),
|
|
144
|
+
primary: resolveColor('primary', '#3b82f6'),
|
|
145
|
+
secondary: resolveColor('success', '#10b981'),
|
|
146
|
+
dots: resolveColor('base-content', '#6b7280')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sizeCanvas() {
|
|
151
|
+
if (!canvas) return
|
|
152
|
+
const parent = canvas.parentElement
|
|
153
|
+
const width = Math.max(160, Math.min(parent?.clientWidth ?? 480, 800))
|
|
154
|
+
const height = 80
|
|
155
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
156
|
+
canvas.width = width
|
|
157
|
+
canvas.height = height
|
|
158
|
+
}
|
|
159
|
+
clearCanvas()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clearCanvas() {
|
|
163
|
+
if (!canvas || !canvasCtx) return
|
|
164
|
+
const { width, height } = canvas
|
|
165
|
+
canvasCtx.clearRect(0, 0, width, height)
|
|
166
|
+
canvasCtx.fillStyle = canvasColors.background
|
|
167
|
+
canvasCtx.fillRect(0, 0, width, height)
|
|
168
|
+
canvasCtx.strokeStyle = canvasColors.centerLine
|
|
169
|
+
canvasCtx.lineWidth = 1
|
|
170
|
+
canvasCtx.beginPath()
|
|
171
|
+
canvasCtx.moveTo(0, height / 2)
|
|
172
|
+
canvasCtx.lineTo(width, height / 2)
|
|
173
|
+
canvasCtx.stroke()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function drawIdleDots() {
|
|
177
|
+
if (!canvas || !canvasCtx) return
|
|
178
|
+
const { width, height } = canvas
|
|
179
|
+
const count = Math.floor(width / 20)
|
|
180
|
+
const spacing = width / (count + 1)
|
|
181
|
+
const t = Date.now() * 0.003
|
|
182
|
+
canvasCtx.fillStyle = canvasColors.dots
|
|
183
|
+
for (let i = 0; i < count; i++) {
|
|
184
|
+
const pulse = Math.sin(t + i * 0.5) * 0.3 + 0.7
|
|
185
|
+
canvasCtx.beginPath()
|
|
186
|
+
canvasCtx.arc((i + 1) * spacing, height / 2, 1.5 * pulse, 0, Math.PI * 2)
|
|
187
|
+
canvasCtx.fill()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function drawWaveform(freq: Uint8Array) {
|
|
192
|
+
if (!canvas || !canvasCtx) return
|
|
193
|
+
const { width, height } = canvas
|
|
194
|
+
canvasCtx.fillStyle = canvasColors.background
|
|
195
|
+
canvasCtx.fillRect(0, 0, width, height)
|
|
196
|
+
canvasCtx.strokeStyle = canvasColors.centerLine
|
|
197
|
+
canvasCtx.lineWidth = 1
|
|
198
|
+
canvasCtx.beginPath()
|
|
199
|
+
canvasCtx.moveTo(0, height / 2)
|
|
200
|
+
canvasCtx.lineTo(width, height / 2)
|
|
201
|
+
canvasCtx.stroke()
|
|
202
|
+
|
|
203
|
+
const barCount = 60
|
|
204
|
+
const gap = 1
|
|
205
|
+
const barWidth = Math.max(1, Math.floor((width - (barCount - 1) * gap) / barCount))
|
|
206
|
+
const centerY = height / 2
|
|
207
|
+
const usable = Math.floor(freq.length * 0.75)
|
|
208
|
+
const perBar = Math.max(1, Math.floor(usable / barCount))
|
|
209
|
+
|
|
210
|
+
const gradient = canvasCtx.createLinearGradient(0, 0, 0, height)
|
|
211
|
+
gradient.addColorStop(0, canvasColors.primary)
|
|
212
|
+
gradient.addColorStop(0.5, canvasColors.secondary)
|
|
213
|
+
gradient.addColorStop(1, canvasColors.primary)
|
|
214
|
+
canvasCtx.fillStyle = gradient
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < barCount; i++) {
|
|
217
|
+
let sum = 0
|
|
218
|
+
for (let j = 0; j < perBar; j++) {
|
|
219
|
+
const idx = i * perBar + j
|
|
220
|
+
if (idx < usable) sum += freq[idx]
|
|
221
|
+
}
|
|
222
|
+
const amplified = (sum / perBar / 255) * 1.5
|
|
223
|
+
const h = Math.max(amplified * (height / 2 - 2), 0.5)
|
|
224
|
+
canvasCtx.fillRect(i * (barWidth + gap), centerY - h, barWidth, h * 2)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let lastCanvasWidth = 0
|
|
229
|
+
/** Re-measure the canvas when its container resizes, without churning every frame. */
|
|
230
|
+
function sizeCanvasIfNeeded() {
|
|
231
|
+
if (!canvas || !canvas.parentElement) return
|
|
232
|
+
const w = Math.max(160, Math.min(canvas.parentElement.clientWidth, 800))
|
|
233
|
+
if (w !== lastCanvasWidth) {
|
|
234
|
+
lastCanvasWidth = w
|
|
235
|
+
sizeCanvas()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function start() {
|
|
240
|
+
// Parity with the pre-factory guard: start is allowed from idle AND done
|
|
241
|
+
// (re-record without reset); the core guards starting/recording/processing.
|
|
242
|
+
if (disabled || phase === 'starting' || phase === 'recording' || phase === 'processing') return
|
|
243
|
+
updateCanvasColors()
|
|
244
|
+
void rec.start()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function pause() {
|
|
248
|
+
rec.pause()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resume() {
|
|
252
|
+
rec.resume()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function stop() {
|
|
256
|
+
rec.stop()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cancel() {
|
|
260
|
+
rec.cancel()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Return a finished/errored recorder to idle so the host can record again. */
|
|
264
|
+
export function reset() {
|
|
265
|
+
rec.reset()
|
|
266
|
+
permissionDenied = false
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function retryAfterError() {
|
|
270
|
+
reset()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
onMount(() => {
|
|
274
|
+
updateCanvasColors()
|
|
275
|
+
const unwatch = watchThemeChanges(() => {
|
|
276
|
+
updateCanvasColors()
|
|
277
|
+
clearCanvas()
|
|
278
|
+
})
|
|
279
|
+
const onResize = () => {
|
|
280
|
+
lastCanvasWidth = 0
|
|
281
|
+
if (canvas) sizeCanvas()
|
|
282
|
+
}
|
|
283
|
+
window.addEventListener('resize', onResize)
|
|
284
|
+
return () => {
|
|
285
|
+
window.removeEventListener('resize', onResize)
|
|
286
|
+
unwatch()
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// While paused the core stops its frame pump — draw the idle dots instead.
|
|
291
|
+
$effect(() => {
|
|
292
|
+
if (phase === 'recording' && paused && canvas) {
|
|
293
|
+
if (!canvasCtx) canvasCtx = canvas.getContext('2d')
|
|
294
|
+
sizeCanvasIfNeeded()
|
|
295
|
+
clearCanvas()
|
|
296
|
+
drawIdleDots()
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Space toggles pause/resume while recording (chimaro behaviour).
|
|
301
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
302
|
+
if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
|
|
303
|
+
e.preventDefault()
|
|
304
|
+
if (paused) resume()
|
|
305
|
+
else pause()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
onDestroy(() => {
|
|
310
|
+
rec.destroy()
|
|
311
|
+
})
|
|
312
|
+
</script>
|
|
313
|
+
|
|
314
|
+
<svelte:document onkeydown={handleKeydown} />
|
|
315
|
+
|
|
316
|
+
<div class={className}>
|
|
317
|
+
{#if variant === 'large'}
|
|
318
|
+
<div class="flex flex-col items-center gap-3">
|
|
319
|
+
{#if phase === 'processing'}
|
|
320
|
+
<div class="badge badge-primary badge-lg gap-2">
|
|
321
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
322
|
+
Processing…
|
|
323
|
+
</div>
|
|
324
|
+
{:else if phase === 'done'}
|
|
325
|
+
<div class="badge badge-success badge-lg">✓ Complete</div>
|
|
326
|
+
{:else if phase === 'starting'}
|
|
327
|
+
<div class="badge badge-neutral badge-lg gap-2">
|
|
328
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
329
|
+
Preparing…
|
|
330
|
+
</div>
|
|
331
|
+
{:else if phase === 'recording' && paused}
|
|
332
|
+
<div class="badge badge-warning badge-lg">⏸ Paused</div>
|
|
333
|
+
{:else if phase === 'recording'}
|
|
334
|
+
<div class="badge badge-error badge-lg animate-pulse">● Recording</div>
|
|
335
|
+
{:else}
|
|
336
|
+
<div class="badge badge-neutral badge-lg">Ready to Record</div>
|
|
337
|
+
{/if}
|
|
338
|
+
|
|
339
|
+
{#if phase === 'recording' || phase === 'processing'}
|
|
340
|
+
<div class="font-mono text-5xl font-bold">{formatDuration(elapsedSec)}</div>
|
|
341
|
+
<div class="text-base-content/60 font-mono text-sm">{formatFileSize(sizeBytes)}</div>
|
|
342
|
+
{/if}
|
|
343
|
+
|
|
344
|
+
{#if phase === 'recording'}
|
|
345
|
+
<div class="relative flex min-h-[96px] items-center justify-center">
|
|
346
|
+
{#if !paused}
|
|
347
|
+
<div class="audio-recorder-pulse"></div>
|
|
348
|
+
{/if}
|
|
349
|
+
<canvas bind:this={canvas} class="border-base-300 bg-base-100 z-10 rounded border"></canvas>
|
|
350
|
+
</div>
|
|
351
|
+
<div class="flex min-h-[20px] items-center gap-2">
|
|
352
|
+
<span class="text-base-content/60 text-xs">Volume:</span>
|
|
353
|
+
<div class="bg-base-300 h-1 w-16 overflow-hidden rounded-full">
|
|
354
|
+
<div
|
|
355
|
+
class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
|
|
356
|
+
style="width: {Math.min(audioLevel, 100)}%"
|
|
357
|
+
></div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
<div class="mt-2 flex items-center gap-3">
|
|
361
|
+
{#if paused}
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
class="btn btn-success btn-circle"
|
|
365
|
+
onclick={resume}
|
|
366
|
+
aria-label="Resume recording"
|
|
367
|
+
>
|
|
368
|
+
<MediaCaptureIcon name="play" size={20} class="fill-current" />
|
|
369
|
+
</button>
|
|
370
|
+
{:else}
|
|
371
|
+
<button
|
|
372
|
+
type="button"
|
|
373
|
+
class="btn btn-warning btn-circle"
|
|
374
|
+
onclick={pause}
|
|
375
|
+
aria-label="Pause recording"
|
|
376
|
+
>
|
|
377
|
+
<MediaCaptureIcon name="pause" size={20} />
|
|
378
|
+
</button>
|
|
379
|
+
{/if}
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
class="btn btn-primary btn-circle"
|
|
383
|
+
onclick={stop}
|
|
384
|
+
aria-label="Stop and save recording"
|
|
385
|
+
>
|
|
386
|
+
<MediaCaptureIcon name="square" size={20} class="fill-current" />
|
|
387
|
+
</button>
|
|
388
|
+
</div>
|
|
389
|
+
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={cancel}>
|
|
390
|
+
<MediaCaptureIcon name="x" size={12} />
|
|
391
|
+
Cancel
|
|
392
|
+
</button>
|
|
393
|
+
<p class="text-base-content/45 text-xs">Space pauses / resumes</p>
|
|
394
|
+
{:else if phase === 'idle'}
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
class="btn btn-error btn-circle btn-xl hover:btn-primary transition-all"
|
|
398
|
+
{disabled}
|
|
399
|
+
onclick={start}
|
|
400
|
+
aria-label="Start voice recording"
|
|
401
|
+
>
|
|
402
|
+
<MediaCaptureIcon name="mic" size={24} />
|
|
403
|
+
</button>
|
|
404
|
+
{/if}
|
|
405
|
+
|
|
406
|
+
{#if errorMessage}
|
|
407
|
+
<div class="alert alert-warning w-full max-w-md text-sm" role="status" aria-live="polite">
|
|
408
|
+
<span>{errorMessage}</span>
|
|
409
|
+
{#if phase === 'idle'}
|
|
410
|
+
<button type="button" class="btn btn-xs" onclick={retryAfterError}>Dismiss</button>
|
|
411
|
+
{/if}
|
|
412
|
+
</div>
|
|
413
|
+
{/if}
|
|
414
|
+
{#if permissionDenied}
|
|
415
|
+
<p class="text-base-content/50 max-w-md text-center text-xs">
|
|
416
|
+
Your browser blocked microphone access. Enable it from the lock icon in the address bar
|
|
417
|
+
(or site settings), then press the record button again.
|
|
418
|
+
</p>
|
|
419
|
+
{/if}
|
|
420
|
+
</div>
|
|
421
|
+
{:else}
|
|
422
|
+
<!-- small: chat-inline pill -->
|
|
423
|
+
<div class="flex items-center gap-2">
|
|
424
|
+
{#if phase === 'idle'}
|
|
425
|
+
{#if supported}
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
class="btn btn-ghost btn-circle btn-sm hover:btn-primary transition-all"
|
|
429
|
+
{disabled}
|
|
430
|
+
onclick={start}
|
|
431
|
+
aria-label="Start voice recording"
|
|
432
|
+
title="Start voice recording"
|
|
433
|
+
>
|
|
434
|
+
<MediaCaptureIcon name="mic" size={14} />
|
|
435
|
+
</button>
|
|
436
|
+
{/if}
|
|
437
|
+
{:else if phase === 'starting'}
|
|
438
|
+
<div class="flex items-center gap-2" role="status" aria-live="polite">
|
|
439
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
440
|
+
<span class="text-base-content/60 text-xs">Preparing…</span>
|
|
441
|
+
</div>
|
|
442
|
+
{:else if phase === 'recording'}
|
|
443
|
+
<div class="bg-base-100 border-base-200 flex items-center gap-2 rounded-lg border p-1.5 shadow-sm">
|
|
444
|
+
{#if paused}
|
|
445
|
+
<button
|
|
446
|
+
type="button"
|
|
447
|
+
class="btn btn-success btn-circle btn-sm"
|
|
448
|
+
onclick={resume}
|
|
449
|
+
aria-label="Resume recording"
|
|
450
|
+
>
|
|
451
|
+
<MediaCaptureIcon name="play" size={14} class="fill-current" />
|
|
452
|
+
</button>
|
|
453
|
+
{:else}
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
class="btn btn-warning btn-circle btn-sm"
|
|
457
|
+
onclick={pause}
|
|
458
|
+
aria-label="Pause recording"
|
|
459
|
+
>
|
|
460
|
+
<MediaCaptureIcon name="pause" size={14} />
|
|
461
|
+
</button>
|
|
462
|
+
{/if}
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
class="btn btn-primary btn-circle btn-sm"
|
|
466
|
+
onclick={stop}
|
|
467
|
+
aria-label="Stop and save recording"
|
|
468
|
+
>
|
|
469
|
+
<MediaCaptureIcon name="square" size={14} class="fill-current" />
|
|
470
|
+
</button>
|
|
471
|
+
<span class="text-base-content/60 w-9 text-center font-mono text-xs">
|
|
472
|
+
{formatDuration(elapsedSec)}
|
|
473
|
+
</span>
|
|
474
|
+
<div class="bg-base-300 h-1 w-8 overflow-hidden rounded-full">
|
|
475
|
+
<div
|
|
476
|
+
class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
|
|
477
|
+
style="width: {Math.min(audioLevel, 100)}%"
|
|
478
|
+
></div>
|
|
479
|
+
</div>
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
class="btn btn-ghost btn-xs text-error"
|
|
483
|
+
onclick={cancel}
|
|
484
|
+
aria-label="Cancel recording"
|
|
485
|
+
>
|
|
486
|
+
<MediaCaptureIcon name="x" size={10} />
|
|
487
|
+
</button>
|
|
488
|
+
</div>
|
|
489
|
+
{:else if phase === 'processing'}
|
|
490
|
+
<div class="flex items-center gap-2" role="status" aria-live="polite">
|
|
491
|
+
<MediaCaptureIcon name="loader" size={14} class="animate-spin" />
|
|
492
|
+
<span class="text-base-content/60 text-xs">Processing…</span>
|
|
493
|
+
</div>
|
|
494
|
+
{:else if phase === 'done'}
|
|
495
|
+
<span class="text-success text-xs">✓ Saved</span>
|
|
496
|
+
<button type="button" class="btn btn-ghost btn-xs" onclick={reset}>Record again</button>
|
|
497
|
+
{/if}
|
|
498
|
+
{#if errorMessage && variant === 'small'}
|
|
499
|
+
<span class="text-warning text-xs" role="status" aria-live="polite">{errorMessage}</span>
|
|
500
|
+
{/if}
|
|
501
|
+
</div>
|
|
502
|
+
{/if}
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
<style>
|
|
506
|
+
.audio-recorder-pulse {
|
|
507
|
+
position: absolute;
|
|
508
|
+
inset: -8px;
|
|
509
|
+
border-radius: 12px;
|
|
510
|
+
background: color-mix(in oklch, var(--color-error) 10%, transparent);
|
|
511
|
+
animation: audio-recorder-pulse 2s ease-in-out infinite;
|
|
512
|
+
}
|
|
513
|
+
@keyframes audio-recorder-pulse {
|
|
514
|
+
0%,
|
|
515
|
+
100% {
|
|
516
|
+
transform: scale(1);
|
|
517
|
+
opacity: 0.3;
|
|
518
|
+
}
|
|
519
|
+
50% {
|
|
520
|
+
transform: scale(1.05);
|
|
521
|
+
opacity: 0.6;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
@media (prefers-reduced-motion: reduce) {
|
|
525
|
+
.audio-recorder-pulse {
|
|
526
|
+
animation: none;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type { AudioRecordingResult } from '../utils/audioRecorderCore';
|
|
2
|
+
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
3
|
+
export declare function formatDuration(totalSeconds: number): string;
|
|
4
|
+
/** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
|
|
5
|
+
export declare function formatFileSize(bytes: number): string;
|
|
6
|
+
interface Props {
|
|
7
|
+
/** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
|
|
8
|
+
variant?: 'small' | 'large';
|
|
9
|
+
/** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
|
|
10
|
+
maxDurationMs?: number;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Fired once the mic stream is live and recording has started. */
|
|
13
|
+
onRecordStart?: () => void;
|
|
14
|
+
onRecordPause?: () => void;
|
|
15
|
+
onRecordResume?: () => void;
|
|
16
|
+
/** Fired with the finished recording. May return a Promise — the
|
|
17
|
+
* component stays in "processing" until it settles, so hosts can
|
|
18
|
+
* upload/transcribe without the user re-triggering a recording. */
|
|
19
|
+
onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>;
|
|
20
|
+
/** Fired when the user cancels — no blob is produced. */
|
|
21
|
+
onRecordCancel?: () => void;
|
|
22
|
+
/** Fired on recorder errors (permission denied, no mic, start failure). */
|
|
23
|
+
onError?: (message: string) => void;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
declare const AudioRecorder: import("svelte").Component<Props, {
|
|
27
|
+
reset: () => void;
|
|
28
|
+
}, "">;
|
|
29
|
+
type AudioRecorder = ReturnType<typeof AudioRecorder>;
|
|
30
|
+
export default AudioRecorder;
|
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* shape as flat actions. Flat + grouped can be mixed; flat actions
|
|
15
15
|
* render first, then grouped dropdowns.
|
|
16
16
|
*
|
|
17
|
+
* Per-action single-key hotkeys: give a flat action a one-character
|
|
18
|
+
* `key` (e.g. `'c'`) and the component registers a window keydown
|
|
19
|
+
* handler that fires `onSelect` for it while the bar is visible and not
|
|
20
|
+
* loading — ignored while the focus is in a text entry target (shared
|
|
21
|
+
* `isTypingTarget`), for modifier combos and for non-printable keys.
|
|
22
|
+
* The key also renders as a `<kbd>` hint on the button.
|
|
23
|
+
*
|
|
24
|
+
* While `loadingMessage` is set, the buttons are replaced by a spinner +
|
|
25
|
+
* message row (matches the IDE's BulkActionBar fork).
|
|
26
|
+
*
|
|
17
27
|
* Usage — flat only:
|
|
18
28
|
*
|
|
19
29
|
* const nav = listNav({ selectable: true });
|
|
@@ -43,12 +53,18 @@
|
|
|
43
53
|
*/
|
|
44
54
|
|
|
45
55
|
import FloatingActionBar from './FloatingActionBar.svelte';
|
|
56
|
+
import { matchBulkHotkey } from './bulkActionBarKeys';
|
|
46
57
|
import type { ActionButton, ActionDropdown } from './FloatingActionBar.svelte';
|
|
47
58
|
|
|
48
59
|
export interface BulkAction {
|
|
49
60
|
label: string;
|
|
50
61
|
/** Optional SVG path string for a 24×24 icon. */
|
|
51
62
|
icon?: string;
|
|
63
|
+
/** Optional single-key hotkey (e.g. `'c'`) — fires `onSelect` while the
|
|
64
|
+
* bar is visible and not loading. Ignored while typing (isTypingTarget),
|
|
65
|
+
* for modifier combos and for non-printable keys. Rendered as a <kbd>
|
|
66
|
+
* hint on the button. */
|
|
67
|
+
key?: string;
|
|
52
68
|
/** Called when the action is clicked. */
|
|
53
69
|
onSelect: () => void;
|
|
54
70
|
/** Renders in the danger variant. Default false. */
|
|
@@ -85,6 +101,10 @@
|
|
|
85
101
|
onClear?: () => void;
|
|
86
102
|
/** Loading state — disables all actions and shows a spinner. */
|
|
87
103
|
loading?: boolean;
|
|
104
|
+
/** When set, replaces the action buttons with a spinner + this message
|
|
105
|
+
* (role="status", aria-live="polite") — the "bulk action in flight"
|
|
106
|
+
* presentation from the IDE's fork. */
|
|
107
|
+
loadingMessage?: string | null;
|
|
88
108
|
/** Label for the count. Default "selected". */
|
|
89
109
|
countLabel?: string;
|
|
90
110
|
}
|
|
@@ -95,6 +115,7 @@
|
|
|
95
115
|
groups = [],
|
|
96
116
|
onClear,
|
|
97
117
|
loading = false,
|
|
118
|
+
loadingMessage = null,
|
|
98
119
|
countLabel = 'selected',
|
|
99
120
|
}: Props = $props();
|
|
100
121
|
|
|
@@ -111,11 +132,29 @@
|
|
|
111
132
|
id: a.id ?? a.label,
|
|
112
133
|
label: a.label,
|
|
113
134
|
icon: a.icon,
|
|
135
|
+
key: a.key,
|
|
114
136
|
variant: a.variant ?? (a.destructive ? 'danger' : 'default'),
|
|
115
137
|
onclick: a.onSelect,
|
|
116
138
|
})),
|
|
117
139
|
);
|
|
118
140
|
|
|
141
|
+
// Single-key hotkeys: fire the matching action's onSelect while the bar
|
|
142
|
+
// is visible and idle. Deciding logic is the pure matchBulkHotkey —
|
|
143
|
+
// typing targets, modifier combos and non-printable keys never fire.
|
|
144
|
+
$effect(() => {
|
|
145
|
+
if (count === 0 || loading || loadingMessage) return;
|
|
146
|
+
const hotkeyed = actions.filter((a) => a.key);
|
|
147
|
+
if (hotkeyed.length === 0) return;
|
|
148
|
+
function onKeydown(event: KeyboardEvent) {
|
|
149
|
+
const action = matchBulkHotkey(event, hotkeyed);
|
|
150
|
+
if (!action) return;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
action.onSelect();
|
|
153
|
+
}
|
|
154
|
+
window.addEventListener('keydown', onKeydown);
|
|
155
|
+
return () => window.removeEventListener('keydown', onKeydown);
|
|
156
|
+
});
|
|
157
|
+
|
|
119
158
|
const mappedDropdowns = $derived<ActionDropdown[]>(
|
|
120
159
|
groups.map((g) => ({
|
|
121
160
|
id: g.id ?? g.label,
|
|
@@ -135,6 +174,7 @@
|
|
|
135
174
|
actions={mapped}
|
|
136
175
|
dropdowns={mappedDropdowns}
|
|
137
176
|
{loading}
|
|
177
|
+
{loadingMessage}
|
|
138
178
|
{onClear}
|
|
139
179
|
{countLabel}
|
|
140
180
|
/>
|
|
@@ -3,6 +3,11 @@ export interface BulkAction {
|
|
|
3
3
|
label: string;
|
|
4
4
|
/** Optional SVG path string for a 24×24 icon. */
|
|
5
5
|
icon?: string;
|
|
6
|
+
/** Optional single-key hotkey (e.g. `'c'`) — fires `onSelect` while the
|
|
7
|
+
* bar is visible and not loading. Ignored while typing (isTypingTarget),
|
|
8
|
+
* for modifier combos and for non-printable keys. Rendered as a <kbd>
|
|
9
|
+
* hint on the button. */
|
|
10
|
+
key?: string;
|
|
6
11
|
/** Called when the action is clicked. */
|
|
7
12
|
onSelect: () => void;
|
|
8
13
|
/** Renders in the danger variant. Default false. */
|
|
@@ -37,6 +42,10 @@ interface Props {
|
|
|
37
42
|
onClear?: () => void;
|
|
38
43
|
/** Loading state — disables all actions and shows a spinner. */
|
|
39
44
|
loading?: boolean;
|
|
45
|
+
/** When set, replaces the action buttons with a spinner + this message
|
|
46
|
+
* (role="status", aria-live="polite") — the "bulk action in flight"
|
|
47
|
+
* presentation from the IDE's fork. */
|
|
48
|
+
loadingMessage?: string | null;
|
|
40
49
|
/** Label for the count. Default "selected". */
|
|
41
50
|
countLabel?: string;
|
|
42
51
|
}
|