@joewinke/jatui 0.23.0 → 0.25.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 +32 -2
- package/dist/components/AudioRecorder.svelte +84 -304
- package/dist/components/AudioRecorder.svelte.d.ts +1 -7
- 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/InputDialog.svelte +49 -38
- package/dist/components/KeyboardShortcutsOverlay.svelte +54 -0
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +30 -0
- package/dist/components/bulkActionBarKeys.d.ts +40 -0
- package/dist/components/bulkActionBarKeys.js +38 -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/session-nav/transcriptModel.d.ts +46 -0
- package/dist/components/session-nav/transcriptModel.js +143 -19
- package/dist/components/toast/ToastItem.svelte +47 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -2
- package/dist/stores/toast.d.ts +8 -0
- package/dist/stores/toast.js +2 -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/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -71,7 +71,7 @@ Then import:
|
|
|
71
71
|
| `Tooltip` | Component-shaped wrapper around the shared `use:tooltip` action — for cases where you can't easily add an attribute to the target element (wrap a chip / badge / icon in the tooltip instead). Warm/cool window + `aria-describedby` are shared with the action. Prefer `use:tooltip` directly when you own the element. | `content: string`, `placement?`, `delayMs?`, `disabled?`, `display?`, `children` (snippet) |
|
|
72
72
|
| `PresenceStack` | "Who's here" avatar stack for live-collaboration UI — max `maxVisible` avatars + "+N more" overflow bubble, tooltip per avatar ("X is editing FIELD" / "X is viewing"). Purely presentational — feed it viewers from any presence source (e.g. an SSE-backed presence composable). | `viewers: PresenceViewer[]`, `maxVisible?` (default 5), `size?`, `class?` |
|
|
73
73
|
| `DirtyFormHost` | Composable form dirty-state manager — renders your form body plus a floating "Unsaved changes" pill + Cancel button; wires `beforeunload` and (optional) SvelteKit `beforeNavigate`. Callers who want bespoke pill/Cancel chrome use `createDirtyGuard()` directly and skip the component. Extracted from the F12 pattern in `/admin/roles/[id]`. | `baseline: T`, `current: T`, `onCancel?`, `beforeNavigate?`, `discardPrompt?`, `equals?`, `hideChrome?`, `children` (snippet with `{dirty, cancel}`) |
|
|
74
|
-
| `BulkActionBar` | Floating action bar that appears when a `listNav` selection is non-empty. Simpler API than `FloatingActionBar`: takes a `Set`, `Array`, or plain number for `selection`; a flat `actions[]` with `{label, onSelect, destructive?}`; and now `groups[]` for dropdown-menu actions (each group is one button on the bar that opens a menu of `{label, onSelect}` items — same shape as `BulkAction`). Flat + grouped can be mixed. Reach for `FloatingActionBar` directly only when you need finer per-item dropdown styling (dot colors, icon snippets). | `selection: Set \| Array \| number`, `actions?: BulkAction[]`, `groups?: BulkActionGroup[]`, `onClear?`, `loading?`, `countLabel?` |
|
|
74
|
+
| `BulkActionBar` | Floating action bar that appears when a `listNav` selection is non-empty. Simpler API than `FloatingActionBar`: takes a `Set`, `Array`, or plain number for `selection`; a flat `actions[]` with `{label, onSelect, destructive?}`; and now `groups[]` for dropdown-menu actions (each group is one button on the bar that opens a menu of `{label, onSelect}` items — same shape as `BulkAction`). Flat + grouped can be mixed. Flat actions take a one-char `key` — the component registers the keydown handler itself (fired only while the bar is visible/idle; ignored while typing via `isTypingTarget`, and for modifier combos), and the key renders as a `<kbd>` hint on the button. A `loadingMessage` replaces the buttons with a spinner + message row while a bulk action is in flight (matches the IDE's fork). Reach for `FloatingActionBar` directly only when you need finer per-item dropdown styling (dot colors, icon snippets). | `selection: Set \| Array \| number`, `actions?: BulkAction[]`, `groups?: BulkActionGroup[]`, `onClear?`, `loading?`, `loadingMessage?`, `countLabel?` |
|
|
75
75
|
| `BatchJobTable` | Generic queue runner (harvested from vid-gen's BatchGenerator) — `<BatchJobTable generics T>` over bindable `JobRow<T>[]`; host supplies async `run(data, {signal, attempt, reportProgress})`. Concurrency cap, optional per-run `processLimit` (quota budget), rolling-average ETA, per-row status badge / progress / elapsed / retry / skip / cancel / reorder — each cell a snippet override (`statusCell`, `progressCell`, `elapsedCell`, `actionsCell`), host data columns via `headers` + `cells`, extra toolbar content via `toolbar`. Uses `showToast` / `showConfirm` (mount `ToastHost` + `ConfirmDialog`). Exposes `start()`/`pause()`/`cancel()`/`retryFailed()`/`clearFinished()`. Pure scheduling helpers exported too (`nextJobsToStart`, `moveJob`, `summarizeJobs`, `toJobRows`). | `jobs` (bindable), `run`, `concurrencyLimit?` (3), `processLimit?`, `autoStart?`, `notify?`, `confirmDestructive?`, `onJobDone?`, `onJobFailed?`, `onBatchDone?` |
|
|
76
76
|
| `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
|
|
77
77
|
| `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
|
|
@@ -150,6 +150,29 @@ import { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from 'jatui
|
|
|
150
150
|
| `AudioWaveform` | Audio waveform visualizer | `audioUrl`, `isPlaying` |
|
|
151
151
|
| `VoicePlayer` | Audio playback with controls | `src`, `autoplay` |
|
|
152
152
|
| `SpeechForm` | Voice-to-text form input | `onTranscript` |
|
|
153
|
+
| `AudioRecorder` | Voice-memo recorder UI (small pill / large panel with canvas waveform) — markup over the headless factory below | `variant`, `maxDurationMs`, `onRecordStart/Pause/Resume/Stop/Cancel`, `onError` |
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// Headless audio capture (jatui-is2ow.8) — the factory behind AudioRecorder.
|
|
157
|
+
// Permission requests, MIME negotiation, record/pause/resume/stop/cancel state
|
|
158
|
+
// machine, pause-aware wall-clock timing, AnalyserNode level meter, blob out.
|
|
159
|
+
// NO transcription — hosts keep their own pipeline and consume onStop's blob
|
|
160
|
+
// (the jat IDE's VoiceMicButton/VoiceInput swap uses it this way).
|
|
161
|
+
import { createAudioRecorder } from "jatui"
|
|
162
|
+
const rec = createAudioRecorder({
|
|
163
|
+
mode: "push", // 'toggle' (default) | 'push' (hold-to-talk: pointerdown→start, pointerup→stop;
|
|
164
|
+
// a stop during stream acquisition cancels silently, matching VoiceMicButton)
|
|
165
|
+
maxDurationMs: 0, // auto-stop ceiling; 0 = until stopped
|
|
166
|
+
onStart?, onPause?, onResume?, onCancel?,
|
|
167
|
+
onStop?, // (result: AudioRecordingResult) — blob/mimeType/durationSeconds/sizeBytes;
|
|
168
|
+
// may be async, recorder stays 'processing' until it settles
|
|
169
|
+
onError?, // (err: { kind, message }) — kind: unsupported|denied|no-mic|start-failed|too-short|handler-failed
|
|
170
|
+
onFrame?, // ({ level, frequency, time }) per animation frame — draw your own visualization
|
|
171
|
+
})
|
|
172
|
+
// Reactive state (Svelte 5 getters): rec.phase ('idle'|'starting'|'recording'|'processing'|'done'),
|
|
173
|
+
// rec.paused, rec.level (0–100), rec.elapsedMs, rec.sizeBytes, rec.error, rec.supported
|
|
174
|
+
// Methods: start() stop() pause() resume() cancel() reset() destroy()
|
|
175
|
+
```
|
|
153
176
|
|
|
154
177
|
### PDF
|
|
155
178
|
|
|
@@ -202,7 +225,7 @@ const callbacks: AnnotationCallbacks = {
|
|
|
202
225
|
|
|
203
226
|
| Component | Purpose | Key Props |
|
|
204
227
|
|-----------|---------|-----------|
|
|
205
|
-
| `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). Precedence: `sections` > `registry` > `shortcuts
|
|
228
|
+
| `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). App-specific sections (marks, list motions, cockpit panels) come in via `extraSections` — rendered after the primary content, before the meta rows, in every mode — plus an `extra` snippet for custom footer content. Precedence: `sections` > `registry` > `shortcuts`; `extraSections`/`extra` always append. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `extraSections?: KeyboardShortcutSection[]`, `extra?: Snippet`, `title?`, `open` (bindable) |
|
|
206
229
|
|
|
207
230
|
**Shortcut registry** (`createShortcutRegistry()`, or the lazy `getShortcutRegistry()` singleton) — the data source for the overlay, so apps stop hand-wiring a keydown handler plus a separate list for the overlay. Shortcuts register against a **context** and return an unregister fn (return it from `$effect`). Contexts form a stack: `global` is the bottom, `pushContext(name, { modal?, label? })` returns a pop fn, the top context shadows the same combo lower down, and a `modal` context suppresses everything beneath it (including `global`). Handled for you: no firing while typing in text inputs/textarea/select/contenteditable (opt in per shortcut with `allowInInput`), IME composition and `defaultPrevented` events are skipped, `mod` = ⌘ on macOS / Ctrl elsewhere, exact modifier matching (`?` doesn't need `shift`), and macOS Option combos fall back to `event.code`. Per shortcut: `enabled()`, `allowRepeat`, `preventDefault`, `group` (overlay section), `hidden`, `label` (keycap override).
|
|
208
231
|
|
|
@@ -315,6 +338,13 @@ import { statusColor, priorityColor, typeIcon, statusLabel } from "jatui"
|
|
|
315
338
|
// Phone formatting
|
|
316
339
|
import { formatPhoneNumber, isValidPhoneNumber } from "jatui"
|
|
317
340
|
|
|
341
|
+
// Clipboard — secure-context aware: falls back to hidden-textarea +
|
|
342
|
+
// document.execCommand('copy') on plain-HTTP origins where navigator.clipboard
|
|
343
|
+
// is undefined (fixes "copy button silently does nothing" over Tailscale/LAN).
|
|
344
|
+
// copyText resolves false when both paths fail; readClipboardText resolves
|
|
345
|
+
// null when the read API is missing or denied.
|
|
346
|
+
import { copyText, readClipboardText } from "jatui"
|
|
347
|
+
|
|
318
348
|
// Currency
|
|
319
349
|
import { formatCurrency } from "jatui"
|
|
320
350
|
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
<script module lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
durationSeconds: number
|
|
7
|
-
sizeBytes: number
|
|
8
|
-
}
|
|
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'
|
|
9
6
|
|
|
10
7
|
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
11
8
|
export function formatDuration(totalSeconds: number): string {
|
|
@@ -41,19 +38,15 @@
|
|
|
41
38
|
* post-recording transcription or analysis consume `onRecordStop`'s
|
|
42
39
|
* blob payload and run their own pipeline (MardukOS Scribe does).
|
|
43
40
|
*
|
|
44
|
-
* What it owns:
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* returns a finished recorder to idle so the host can record again.
|
|
51
|
-
*
|
|
52
|
-
* Timer discipline (lesson from MediaCapture, mos-ow4q4.37): elapsed time
|
|
53
|
-
* is derived from the wall clock minus accumulated pause, never an
|
|
54
|
-
* incremented counter — a throttled background tab can't freeze it.
|
|
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.
|
|
55
47
|
*/
|
|
56
48
|
import { onDestroy, onMount } from 'svelte'
|
|
49
|
+
import { createAudioRecorder } from '../utils/audioRecorder'
|
|
57
50
|
import { getDaisyUIColor, watchThemeChanges } from '../utils/daisyuiColors'
|
|
58
51
|
import MediaCaptureIcon from './MediaCaptureIcon.svelte'
|
|
59
52
|
|
|
@@ -91,42 +84,49 @@
|
|
|
91
84
|
class: className = ''
|
|
92
85
|
}: Props = $props()
|
|
93
86
|
|
|
94
|
-
type Phase = 'idle' | 'recording' | 'processing' | 'done'
|
|
95
|
-
type PauseState = 'active' | 'paused'
|
|
87
|
+
type Phase = 'idle' | 'starting' | 'recording' | 'processing' | 'done'
|
|
96
88
|
|
|
97
|
-
let phase = $state<Phase>('idle')
|
|
98
|
-
let paused = $state<PauseState>('active')
|
|
99
89
|
let permissionDenied = $state(false)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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)
|
|
119
124
|
|
|
120
125
|
// Canvas
|
|
121
126
|
let canvas = $state<HTMLCanvasElement | null>(null)
|
|
122
127
|
let canvasCtx: CanvasRenderingContext2D | null = null
|
|
123
128
|
let canvasColors = { background: '', centerLine: '', primary: '', secondary: '', dots: '' }
|
|
124
129
|
|
|
125
|
-
const hasUserMedia =
|
|
126
|
-
typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
|
127
|
-
const hasRecorder = typeof MediaRecorder !== 'undefined'
|
|
128
|
-
const supported = hasRecorder && hasUserMedia
|
|
129
|
-
|
|
130
130
|
function resolveColor(name: string, fallback: string): string {
|
|
131
131
|
if (typeof window === 'undefined') return fallback
|
|
132
132
|
try {
|
|
@@ -147,79 +147,6 @@
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
function pickMime(): string {
|
|
151
|
-
for (const c of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']) {
|
|
152
|
-
if (MediaRecorder.isTypeSupported(c)) return c
|
|
153
|
-
}
|
|
154
|
-
return ''
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Elapsed from the wall clock, pause-adjusted — see header note. */
|
|
158
|
-
function elapsedMs(): number {
|
|
159
|
-
if (!startedAt) return 0
|
|
160
|
-
const pausedMs = pausedTotalMs + (pauseStartedAt ? Date.now() - pauseStartedAt : 0)
|
|
161
|
-
return Math.max(0, Date.now() - startedAt - pausedMs)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function tick() {
|
|
165
|
-
elapsedSec = Math.floor(elapsedMs() / 1000)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function stopAllTracks() {
|
|
169
|
-
stream?.getTracks().forEach((t) => t.stop())
|
|
170
|
-
stream = null
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function stopAnimation() {
|
|
174
|
-
if (raf !== null) {
|
|
175
|
-
cancelAnimationFrame(raf)
|
|
176
|
-
raf = null
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function clearTimers() {
|
|
181
|
-
if (tickTimer) {
|
|
182
|
-
clearInterval(tickTimer)
|
|
183
|
-
tickTimer = null
|
|
184
|
-
}
|
|
185
|
-
if (autoStopTimer) {
|
|
186
|
-
clearTimeout(autoStopTimer)
|
|
187
|
-
autoStopTimer = null
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Tear down every media resource. Idempotent — safe from cancel/stop/destroy. */
|
|
192
|
-
function cleanup() {
|
|
193
|
-
wasCancelled = true
|
|
194
|
-
if (recorder && recorder.state !== 'inactive') {
|
|
195
|
-
recorder.ondataavailable = null
|
|
196
|
-
recorder.onstop = null
|
|
197
|
-
try {
|
|
198
|
-
recorder.stop()
|
|
199
|
-
} catch {
|
|
200
|
-
// already inactive
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
recorder = null
|
|
204
|
-
stopAllTracks()
|
|
205
|
-
if (audioCtx && audioCtx.state !== 'closed') {
|
|
206
|
-
audioCtx.close().catch(() => {})
|
|
207
|
-
}
|
|
208
|
-
audioCtx = null
|
|
209
|
-
analyser = null
|
|
210
|
-
stopAnimation()
|
|
211
|
-
clearTimers()
|
|
212
|
-
chunks = []
|
|
213
|
-
sizeBytes = 0
|
|
214
|
-
audioLevel = 0
|
|
215
|
-
startedAt = 0
|
|
216
|
-
pausedTotalMs = 0
|
|
217
|
-
pauseStartedAt = 0
|
|
218
|
-
elapsedSec = 0
|
|
219
|
-
paused = 'active'
|
|
220
|
-
clearCanvas()
|
|
221
|
-
}
|
|
222
|
-
|
|
223
150
|
function sizeCanvas() {
|
|
224
151
|
if (!canvas) return
|
|
225
152
|
const parent = canvas.parentElement
|
|
@@ -298,39 +225,6 @@
|
|
|
298
225
|
}
|
|
299
226
|
}
|
|
300
227
|
|
|
301
|
-
function animate() {
|
|
302
|
-
if (!analyser || phase !== 'recording') return
|
|
303
|
-
if (paused === 'paused') {
|
|
304
|
-
if (canvas) {
|
|
305
|
-
sizeCanvasIfNeeded()
|
|
306
|
-
clearCanvas()
|
|
307
|
-
drawIdleDots()
|
|
308
|
-
}
|
|
309
|
-
audioLevel = 0
|
|
310
|
-
raf = requestAnimationFrame(animate)
|
|
311
|
-
return
|
|
312
|
-
}
|
|
313
|
-
const freq = new Uint8Array(analyser.frequencyBinCount)
|
|
314
|
-
const time = new Uint8Array(analyser.frequencyBinCount)
|
|
315
|
-
analyser.getByteFrequencyData(freq)
|
|
316
|
-
analyser.getByteTimeDomainData(time)
|
|
317
|
-
let sum = 0
|
|
318
|
-
for (let i = 0; i < time.length; i++) {
|
|
319
|
-
const s = (time[i] - 128) / 128
|
|
320
|
-
sum += s * s
|
|
321
|
-
}
|
|
322
|
-
audioLevel = Math.min(100, Math.sqrt(sum / time.length) * 300)
|
|
323
|
-
if (canvas) {
|
|
324
|
-
sizeCanvasIfNeeded()
|
|
325
|
-
if (freq.some((v) => v > 0)) drawWaveform(freq)
|
|
326
|
-
else {
|
|
327
|
-
clearCanvas()
|
|
328
|
-
drawIdleDots()
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
raf = requestAnimationFrame(animate)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
228
|
let lastCanvasWidth = 0
|
|
335
229
|
/** Re-measure the canvas when its container resizes, without churning every frame. */
|
|
336
230
|
function sizeCanvasIfNeeded() {
|
|
@@ -342,172 +236,38 @@
|
|
|
342
236
|
}
|
|
343
237
|
}
|
|
344
238
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
352
|
-
})
|
|
353
|
-
mimeType = pickMime()
|
|
354
|
-
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
|
|
355
|
-
mimeType = recorder.mimeType || mimeType || 'audio/webm'
|
|
356
|
-
chunks = []
|
|
357
|
-
sizeBytes = 0
|
|
358
|
-
wasCancelled = false
|
|
359
|
-
|
|
360
|
-
recorder.ondataavailable = (e) => {
|
|
361
|
-
if (e.data.size > 0) {
|
|
362
|
-
chunks.push(e.data)
|
|
363
|
-
sizeBytes += e.data.size
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
recorder.onstop = () => {
|
|
367
|
-
void finalize()
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Waveform analyser — visualization only, never touches the recorded data.
|
|
371
|
-
audioCtx = new AudioContext()
|
|
372
|
-
analyser = audioCtx.createAnalyser()
|
|
373
|
-
analyser.fftSize = 512
|
|
374
|
-
analyser.smoothingTimeConstant = 0.3
|
|
375
|
-
analyser.minDecibels = -100
|
|
376
|
-
analyser.maxDecibels = -20
|
|
377
|
-
audioCtx.createMediaStreamSource(stream).connect(analyser)
|
|
378
|
-
|
|
379
|
-
recorder.start(1000)
|
|
380
|
-
startedAt = Date.now()
|
|
381
|
-
pausedTotalMs = 0
|
|
382
|
-
pauseStartedAt = 0
|
|
383
|
-
elapsedSec = 0
|
|
384
|
-
paused = 'active'
|
|
385
|
-
phase = 'recording'
|
|
386
|
-
tickTimer = setInterval(tick, 250)
|
|
387
|
-
if (maxDurationMs > 0) {
|
|
388
|
-
autoStopTimer = setTimeout(stop, maxDurationMs)
|
|
389
|
-
}
|
|
390
|
-
updateCanvasColors()
|
|
391
|
-
// Canvas mounts with the recording UI — give Svelte a tick, then go live.
|
|
392
|
-
setTimeout(() => {
|
|
393
|
-
if (canvas) {
|
|
394
|
-
canvasCtx = canvas.getContext('2d')
|
|
395
|
-
lastCanvasWidth = 0
|
|
396
|
-
sizeCanvas()
|
|
397
|
-
}
|
|
398
|
-
stopAnimation()
|
|
399
|
-
raf = requestAnimationFrame(animate)
|
|
400
|
-
}, 0)
|
|
401
|
-
onRecordStart?.()
|
|
402
|
-
} catch (err) {
|
|
403
|
-
cleanup()
|
|
404
|
-
phase = 'idle'
|
|
405
|
-
const name = (err as DOMException)?.name ?? ''
|
|
406
|
-
let message: string
|
|
407
|
-
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
|
408
|
-
permissionDenied = true
|
|
409
|
-
message = 'Microphone access was denied. Allow mic permission in your browser settings and try again.'
|
|
410
|
-
} else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
|
411
|
-
message = 'No microphone was found. Connect one and try again.'
|
|
412
|
-
} else {
|
|
413
|
-
message = 'Failed to start recording. Check the microphone and try again.'
|
|
414
|
-
}
|
|
415
|
-
errorMessage = message
|
|
416
|
-
onError?.(message)
|
|
417
|
-
}
|
|
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()
|
|
418
245
|
}
|
|
419
246
|
|
|
420
247
|
function pause() {
|
|
421
|
-
|
|
422
|
-
try {
|
|
423
|
-
recorder.pause()
|
|
424
|
-
paused = 'paused'
|
|
425
|
-
pauseStartedAt = Date.now()
|
|
426
|
-
stopAnimation()
|
|
427
|
-
onRecordPause?.()
|
|
428
|
-
} catch {
|
|
429
|
-
// recorder already inactive — nothing to pause
|
|
430
|
-
}
|
|
248
|
+
rec.pause()
|
|
431
249
|
}
|
|
432
250
|
|
|
433
251
|
function resume() {
|
|
434
|
-
|
|
435
|
-
try {
|
|
436
|
-
recorder.resume()
|
|
437
|
-
pausedTotalMs += Date.now() - pauseStartedAt
|
|
438
|
-
pauseStartedAt = 0
|
|
439
|
-
paused = 'active'
|
|
440
|
-
stopAnimation()
|
|
441
|
-
raf = requestAnimationFrame(animate)
|
|
442
|
-
onRecordResume?.()
|
|
443
|
-
} catch {
|
|
444
|
-
// recorder already inactive — nothing to resume
|
|
445
|
-
}
|
|
252
|
+
rec.resume()
|
|
446
253
|
}
|
|
447
254
|
|
|
448
255
|
function stop() {
|
|
449
|
-
|
|
450
|
-
if (pauseStartedAt) {
|
|
451
|
-
pausedTotalMs += Date.now() - pauseStartedAt
|
|
452
|
-
pauseStartedAt = 0
|
|
453
|
-
}
|
|
454
|
-
clearTimers()
|
|
455
|
-
stopAnimation()
|
|
456
|
-
if (recorder.state !== 'inactive') {
|
|
457
|
-
phase = 'processing'
|
|
458
|
-
recorder.stop()
|
|
459
|
-
}
|
|
256
|
+
rec.stop()
|
|
460
257
|
}
|
|
461
258
|
|
|
462
259
|
function cancel() {
|
|
463
|
-
|
|
464
|
-
cleanup()
|
|
465
|
-
phase = 'idle'
|
|
466
|
-
onRecordCancel?.()
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
async function finalize() {
|
|
470
|
-
if (wasCancelled || chunks.length === 0) {
|
|
471
|
-
if (!wasCancelled) {
|
|
472
|
-
// Zero chunks (e.g. stopped before any data arrived) — fail loud, not a silent no-op.
|
|
473
|
-
phase = 'idle'
|
|
474
|
-
errorMessage = 'Recording was too short — nothing was captured. Try again.'
|
|
475
|
-
onError?.(errorMessage)
|
|
476
|
-
}
|
|
477
|
-
return
|
|
478
|
-
}
|
|
479
|
-
phase = 'processing'
|
|
480
|
-
const blob = new Blob(chunks, { type: mimeType })
|
|
481
|
-
const result: AudioRecordingResult = {
|
|
482
|
-
blob,
|
|
483
|
-
mimeType,
|
|
484
|
-
durationSeconds: Math.max(1, Math.round(elapsedMs() / 1000)),
|
|
485
|
-
sizeBytes: blob.size
|
|
486
|
-
}
|
|
487
|
-
cleanup()
|
|
488
|
-
phase = 'done'
|
|
489
|
-
if (onRecordStop) {
|
|
490
|
-
try {
|
|
491
|
-
await onRecordStop(result)
|
|
492
|
-
} catch (err) {
|
|
493
|
-
errorMessage = `Recording kept, but the handler failed: ${err instanceof Error ? err.message : String(err)}`
|
|
494
|
-
onError?.(errorMessage)
|
|
495
|
-
}
|
|
496
|
-
}
|
|
260
|
+
rec.cancel()
|
|
497
261
|
}
|
|
498
262
|
|
|
499
263
|
/** Return a finished/errored recorder to idle so the host can record again. */
|
|
500
264
|
export function reset() {
|
|
501
|
-
|
|
502
|
-
phase = 'idle'
|
|
503
|
-
errorMessage = ''
|
|
265
|
+
rec.reset()
|
|
504
266
|
permissionDenied = false
|
|
505
267
|
}
|
|
506
268
|
|
|
507
269
|
function retryAfterError() {
|
|
508
|
-
|
|
509
|
-
permissionDenied = false
|
|
510
|
-
phase = 'idle'
|
|
270
|
+
reset()
|
|
511
271
|
}
|
|
512
272
|
|
|
513
273
|
onMount(() => {
|
|
@@ -527,17 +287,27 @@
|
|
|
527
287
|
}
|
|
528
288
|
})
|
|
529
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
|
+
|
|
530
300
|
// Space toggles pause/resume while recording (chimaro behaviour).
|
|
531
301
|
function handleKeydown(e: KeyboardEvent) {
|
|
532
302
|
if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
|
|
533
303
|
e.preventDefault()
|
|
534
|
-
if (paused
|
|
304
|
+
if (paused) resume()
|
|
535
305
|
else pause()
|
|
536
306
|
}
|
|
537
307
|
}
|
|
538
308
|
|
|
539
309
|
onDestroy(() => {
|
|
540
|
-
|
|
310
|
+
rec.destroy()
|
|
541
311
|
})
|
|
542
312
|
</script>
|
|
543
313
|
|
|
@@ -553,7 +323,12 @@
|
|
|
553
323
|
</div>
|
|
554
324
|
{:else if phase === 'done'}
|
|
555
325
|
<div class="badge badge-success badge-lg">✓ Complete</div>
|
|
556
|
-
{:else if phase === '
|
|
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}
|
|
557
332
|
<div class="badge badge-warning badge-lg">⏸ Paused</div>
|
|
558
333
|
{:else if phase === 'recording'}
|
|
559
334
|
<div class="badge badge-error badge-lg animate-pulse">● Recording</div>
|
|
@@ -568,7 +343,7 @@
|
|
|
568
343
|
|
|
569
344
|
{#if phase === 'recording'}
|
|
570
345
|
<div class="relative flex min-h-[96px] items-center justify-center">
|
|
571
|
-
{#if paused
|
|
346
|
+
{#if !paused}
|
|
572
347
|
<div class="audio-recorder-pulse"></div>
|
|
573
348
|
{/if}
|
|
574
349
|
<canvas bind:this={canvas} class="border-base-300 bg-base-100 z-10 rounded border"></canvas>
|
|
@@ -583,7 +358,7 @@
|
|
|
583
358
|
</div>
|
|
584
359
|
</div>
|
|
585
360
|
<div class="mt-2 flex items-center gap-3">
|
|
586
|
-
{#if paused
|
|
361
|
+
{#if paused}
|
|
587
362
|
<button
|
|
588
363
|
type="button"
|
|
589
364
|
class="btn btn-success btn-circle"
|
|
@@ -659,9 +434,14 @@
|
|
|
659
434
|
<MediaCaptureIcon name="mic" size={14} />
|
|
660
435
|
</button>
|
|
661
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>
|
|
662
442
|
{:else if phase === 'recording'}
|
|
663
443
|
<div class="bg-base-100 border-base-200 flex items-center gap-2 rounded-lg border p-1.5 shadow-sm">
|
|
664
|
-
{#if paused
|
|
444
|
+
{#if paused}
|
|
665
445
|
<button
|
|
666
446
|
type="button"
|
|
667
447
|
class="btn btn-success btn-circle btn-sm"
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export interface AudioRecordingResult {
|
|
3
|
-
blob: Blob;
|
|
4
|
-
mimeType: string;
|
|
5
|
-
durationSeconds: number;
|
|
6
|
-
sizeBytes: number;
|
|
7
|
-
}
|
|
1
|
+
export type { AudioRecordingResult } from '../utils/audioRecorderCore';
|
|
8
2
|
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
9
3
|
export declare function formatDuration(totalSeconds: number): string;
|
|
10
4
|
/** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
|
|
@@ -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
|
/>
|