@joewinke/jatui 0.1.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.
Files changed (62) hide show
  1. package/package.json +46 -0
  2. package/src/lib/components/AudioWaveform.svelte +694 -0
  3. package/src/lib/components/AvailabilityModal.svelte +173 -0
  4. package/src/lib/components/Badge.svelte +38 -0
  5. package/src/lib/components/BookingForm.svelte +276 -0
  6. package/src/lib/components/Button.svelte +72 -0
  7. package/src/lib/components/CalendarPicker.svelte +284 -0
  8. package/src/lib/components/Card.svelte +67 -0
  9. package/src/lib/components/CharacterCounter.svelte +82 -0
  10. package/src/lib/components/ChipInput.svelte +596 -0
  11. package/src/lib/components/ColorSelector.svelte +163 -0
  12. package/src/lib/components/ConfirmModal.svelte +75 -0
  13. package/src/lib/components/CountdownTimer.svelte +94 -0
  14. package/src/lib/components/DateRangePicker.svelte +192 -0
  15. package/src/lib/components/Drawer.svelte +110 -0
  16. package/src/lib/components/FilterDropdown.svelte +202 -0
  17. package/src/lib/components/ImageUpload.svelte +97 -0
  18. package/src/lib/components/InlineEdit.svelte +283 -0
  19. package/src/lib/components/LazyImage.svelte +122 -0
  20. package/src/lib/components/LoadingSpinner.svelte +102 -0
  21. package/src/lib/components/Modal.svelte +208 -0
  22. package/src/lib/components/PhoneInput.svelte +92 -0
  23. package/src/lib/components/ResizableDivider.svelte +305 -0
  24. package/src/lib/components/ResizablePanel.svelte +302 -0
  25. package/src/lib/components/SearchDropdown.svelte +341 -0
  26. package/src/lib/components/SelectInput.svelte +215 -0
  27. package/src/lib/components/SignaturePad.svelte +171 -0
  28. package/src/lib/components/SortDropdown.svelte +148 -0
  29. package/src/lib/components/Sparkline.svelte +107 -0
  30. package/src/lib/components/SpeechForm.svelte +114 -0
  31. package/src/lib/components/StatusBadge.svelte +155 -0
  32. package/src/lib/components/TextArea.svelte +143 -0
  33. package/src/lib/components/TextInput.svelte +108 -0
  34. package/src/lib/components/ThemeSelector.svelte +195 -0
  35. package/src/lib/components/TimeSlotPicker.svelte +162 -0
  36. package/src/lib/components/VoicePlayer.svelte +420 -0
  37. package/src/lib/components/messaging/Avatar.svelte +81 -0
  38. package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
  39. package/src/lib/components/messaging/ChannelList.svelte +107 -0
  40. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
  41. package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
  42. package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
  43. package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
  44. package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
  45. package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
  46. package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
  47. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
  48. package/src/lib/components/messaging/MessageInput.svelte +451 -0
  49. package/src/lib/components/messaging/MessageItem.svelte +338 -0
  50. package/src/lib/components/messaging/MessageThread.svelte +306 -0
  51. package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
  52. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
  53. package/src/lib/components/messaging/StartDMModal.svelte +100 -0
  54. package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
  55. package/src/lib/index.ts +185 -0
  56. package/src/lib/types/booking.ts +143 -0
  57. package/src/lib/types/messaging.ts +459 -0
  58. package/src/lib/utils/currency.ts +20 -0
  59. package/src/lib/utils/daisyuiColors.ts +243 -0
  60. package/src/lib/utils/dateFormatters.ts +153 -0
  61. package/src/lib/utils/mentionParser.ts +188 -0
  62. package/src/lib/utils/phoneFormat.ts +74 -0
@@ -0,0 +1,694 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from "svelte"
3
+ import type { Snippet } from "svelte"
4
+
5
+ import {
6
+ getDaisyUIColor,
7
+ lightenColor,
8
+ darkenColor,
9
+ watchThemeChanges,
10
+ } from "../utils/daisyuiColors"
11
+
12
+ let {
13
+ audioUrl,
14
+ height = 80,
15
+ waveColor = "neutral",
16
+ progressColor = "accent",
17
+ backgroundColor = "base-100",
18
+ waveColorHex = "#6b7280",
19
+ progressColorHex = "#f97316",
20
+ backgroundColorHex = "#f1f5f9",
21
+ interactive = true,
22
+ showTrimControls = false,
23
+ onTrim,
24
+ providedDuration,
25
+ speedControls,
26
+ } = $props<{
27
+ audioUrl: string
28
+ height?: number
29
+ waveColor?: string
30
+ progressColor?: string
31
+ backgroundColor?: string
32
+ waveColorHex?: string
33
+ progressColorHex?: string
34
+ backgroundColorHex?: string
35
+ interactive?: boolean
36
+ showTrimControls?: boolean
37
+ onTrim?: (startTime: number, endTime: number) => void
38
+ providedDuration?: number
39
+ speedControls?: Snippet
40
+ }>()
41
+
42
+ let canvas = $state<HTMLCanvasElement | null>(null)
43
+ let audio = $state<HTMLAudioElement | null>(null)
44
+ let canvasContext: CanvasRenderingContext2D | null = null
45
+
46
+ let isPlaying = $state(false)
47
+ let currentTime = $state(0)
48
+ let duration = $state(0)
49
+ let isLoading = $state(true)
50
+ let waveformData: number[] = []
51
+
52
+ let hoverX = $state(0)
53
+ let isHovering = $state(false)
54
+ let hoverTime = $state(0)
55
+
56
+ let trimStart = $state(0)
57
+ let trimEnd = $state(0)
58
+ let isDraggingStart = $state(false)
59
+ let isDraggingEnd = $state(false)
60
+ let isInTrimMode = $state(false)
61
+
62
+ function resolveColor(daisyUIName: string, hexFallback: string): string {
63
+ if (typeof window === "undefined") return hexFallback
64
+ try {
65
+ if (daisyUIName.startsWith("#")) return daisyUIName
66
+ return getDaisyUIColor(daisyUIName)
67
+ } catch {
68
+ return hexFallback
69
+ }
70
+ }
71
+
72
+ let resolvedWaveColor = $state(resolveColor(waveColor, waveColorHex))
73
+ let resolvedProgressColor = $state(resolveColor(progressColor, progressColorHex))
74
+ let resolvedBackgroundColor = $state(resolveColor(backgroundColor, backgroundColorHex))
75
+
76
+ function updateResolvedColors() {
77
+ resolvedWaveColor = resolveColor(waveColor, waveColorHex)
78
+ resolvedProgressColor = resolveColor(progressColor, progressColorHex)
79
+ resolvedBackgroundColor = resolveColor(backgroundColor, backgroundColorHex)
80
+ }
81
+
82
+ onMount(async () => {
83
+ const cleanupThemeWatcher = watchThemeChanges(() => {
84
+ updateResolvedColors()
85
+ if (canvasContext && canvas && waveformData.length > 0) {
86
+ drawWaveform()
87
+ }
88
+ })
89
+
90
+ if (canvas) {
91
+ canvasContext = canvas.getContext("2d")
92
+
93
+ setTimeout(() => {
94
+ if (canvas && canvasContext) {
95
+ const container = canvas.parentElement
96
+ if (container) {
97
+ const containerRect = container.getBoundingClientRect()
98
+ const displayWidth = containerRect.width
99
+ const displayHeight = height
100
+
101
+ canvas.width = displayWidth
102
+ canvas.height = displayHeight
103
+ canvas.style.width = displayWidth + "px"
104
+ canvas.style.height = displayHeight + "px"
105
+
106
+ updateResolvedColors()
107
+ loadAudioAndGenerateWaveform()
108
+ }
109
+ }
110
+ }, 100)
111
+ }
112
+
113
+ if (audio) {
114
+ audio.load()
115
+ }
116
+
117
+ return cleanupThemeWatcher
118
+ })
119
+
120
+ async function loadAudioAndGenerateWaveform() {
121
+ try {
122
+ isLoading = true
123
+ generateSimpleWaveform()
124
+ isLoading = false
125
+ } catch {
126
+ isLoading = false
127
+ }
128
+ }
129
+
130
+ function generateSimpleWaveform() {
131
+ if (!canvas) return
132
+
133
+ const canvasWidth = canvas.width
134
+
135
+ if (waveformData.length === 0 || waveformData.length !== canvasWidth) {
136
+ waveformData = []
137
+
138
+ const seed = audioUrl
139
+ ? audioUrl.split("").reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0)
140
+ : 12345
141
+
142
+ for (let i = 0; i < canvasWidth; i++) {
143
+ const progress = i / canvasWidth
144
+ const baseAmplitude = Math.sin(progress * Math.PI * 2 + seed * 0.001) * 0.3 + 0.5
145
+ const speechPattern = Math.sin(progress * Math.PI * 20 + seed * 0.002) * 0.2
146
+ const variation = Math.sin(progress * Math.PI * 8 + seed * 0.003) * 0.1
147
+
148
+ let amplitude = baseAmplitude + speechPattern + variation
149
+ amplitude = Math.max(0.1, Math.min(0.9, amplitude))
150
+
151
+ waveformData.push(amplitude)
152
+ }
153
+ }
154
+
155
+ drawWaveform()
156
+ }
157
+
158
+ function createWaveformGradient() {
159
+ if (!canvasContext || !canvas) return null
160
+
161
+ const gradient = canvasContext.createLinearGradient(0, 0, 0, canvas.height)
162
+ const neutralColor = resolvedWaveColor
163
+ const neutralContentColor = resolveColor("neutral-content", "#ffffff")
164
+ const lighterNeutral = lightenColor(neutralColor, 0.2)
165
+
166
+ gradient.addColorStop(0, neutralColor)
167
+ gradient.addColorStop(0.7, neutralColor)
168
+ gradient.addColorStop(0.71, neutralContentColor)
169
+ gradient.addColorStop(0.72, neutralContentColor)
170
+ gradient.addColorStop(0.73, lighterNeutral)
171
+ gradient.addColorStop(1, lighterNeutral)
172
+
173
+ return gradient
174
+ }
175
+
176
+ function createProgressGradient() {
177
+ if (!canvasContext || !canvas) return null
178
+
179
+ const gradient = canvasContext.createLinearGradient(0, 0, 0, canvas.height)
180
+ const accentColor = resolvedProgressColor
181
+ const accentContentColor = resolveColor("accent-content", "#ffffff")
182
+ const darkerAccent = darkenColor(accentColor, 0.1)
183
+ const lighterAccent = lightenColor(accentColor, 0.3)
184
+
185
+ gradient.addColorStop(0, accentColor)
186
+ gradient.addColorStop(0.7, darkerAccent)
187
+ gradient.addColorStop(0.71, accentContentColor)
188
+ gradient.addColorStop(0.72, accentContentColor)
189
+ gradient.addColorStop(0.73, lighterAccent)
190
+ gradient.addColorStop(1, lighterAccent)
191
+
192
+ return gradient
193
+ }
194
+
195
+ function drawBaseWaveform() {
196
+ if (!canvasContext || !canvas || waveformData.length === 0) return
197
+
198
+ const { width, height } = canvas
199
+ canvasContext.clearRect(0, 0, width, height)
200
+
201
+ canvasContext.fillStyle = resolvedBackgroundColor
202
+ canvasContext.fillRect(0, 0, width, height)
203
+
204
+ const barWidth = 2
205
+ const barSpacing = 1
206
+ const totalBarWidth = barWidth + barSpacing
207
+ const numBars = Math.floor(width / totalBarWidth)
208
+ const centerY = height / 2
209
+
210
+ const validDuration = isFinite(duration) && duration > 0
211
+ const progressX = validDuration ? (currentTime / duration) * width : 0
212
+ const trimStartX = validDuration ? (trimStart / duration) * width : 0
213
+ const trimEndX = validDuration ? (trimEnd / duration) * width : width
214
+
215
+ const waveGradient = createWaveformGradient()
216
+ const progressGradient = createProgressGradient()
217
+
218
+ for (let i = 0; i < numBars; i++) {
219
+ const dataIndex = Math.floor((i / numBars) * waveformData.length)
220
+ const amplitude = waveformData[dataIndex] || 0.1
221
+ const barHeight = Math.max(4, amplitude * height * 0.8)
222
+ const x = i * totalBarWidth
223
+ const y = centerY - barHeight / 2
224
+
225
+ let fillStyle = waveGradient || waveColor
226
+ if (showTrimControls && isInTrimMode) {
227
+ if (x < trimStartX || x > trimEndX) {
228
+ canvasContext.globalAlpha = 0.3
229
+ } else if (x < progressX) {
230
+ fillStyle = progressGradient || progressColor
231
+ canvasContext.globalAlpha = 1
232
+ } else {
233
+ canvasContext.globalAlpha = 1
234
+ }
235
+ } else if (x < progressX) {
236
+ fillStyle = progressGradient || progressColor
237
+ canvasContext.globalAlpha = 1
238
+ } else {
239
+ canvasContext.globalAlpha = 1
240
+ }
241
+
242
+ canvasContext.fillStyle = fillStyle
243
+ canvasContext.fillRect(x, y, barWidth, barHeight)
244
+ }
245
+
246
+ canvasContext.globalAlpha = 1
247
+ }
248
+
249
+ function drawOverlays() {
250
+ if (!canvasContext || !canvas) return
251
+
252
+ const { width, height } = canvas
253
+
254
+ const validDuration = isFinite(duration) && duration > 0
255
+ const progressX = validDuration ? (currentTime / duration) * width : 0
256
+ const trimStartX = validDuration ? (trimStart / duration) * width : 0
257
+ const trimEndX = validDuration ? (trimEnd / duration) * width : width
258
+
259
+ if (isHovering && hoverX > 0) {
260
+ canvasContext.fillStyle = "rgba(255, 255, 255, 0.15)"
261
+ const rect = canvas.getBoundingClientRect()
262
+ const canvasHoverX = (hoverX / rect.width) * width
263
+ canvasContext.fillRect(0, 0, canvasHoverX, height)
264
+
265
+ canvasContext.strokeStyle = "rgba(255, 255, 255, 0.6)"
266
+ canvasContext.lineWidth = 1
267
+ canvasContext.beginPath()
268
+ canvasContext.moveTo(canvasHoverX, 0)
269
+ canvasContext.lineTo(canvasHoverX, height)
270
+ canvasContext.stroke()
271
+ }
272
+
273
+ if (progressX > 0) {
274
+ canvasContext.strokeStyle = resolvedProgressColor
275
+ canvasContext.lineWidth = 2
276
+ canvasContext.beginPath()
277
+ canvasContext.moveTo(progressX, 0)
278
+ canvasContext.lineTo(progressX, height)
279
+ canvasContext.stroke()
280
+ }
281
+
282
+ if (showTrimControls && isInTrimMode) {
283
+ canvasContext.strokeStyle = "#ef4444"
284
+ canvasContext.lineWidth = 3
285
+ canvasContext.beginPath()
286
+ canvasContext.moveTo(trimStartX, 0)
287
+ canvasContext.lineTo(trimStartX, height)
288
+ canvasContext.stroke()
289
+
290
+ canvasContext.strokeStyle = "#ef4444"
291
+ canvasContext.lineWidth = 3
292
+ canvasContext.beginPath()
293
+ canvasContext.moveTo(trimEndX, 0)
294
+ canvasContext.lineTo(trimEndX, height)
295
+ canvasContext.stroke()
296
+
297
+ canvasContext.fillStyle = "rgba(59, 130, 246, 0.1)"
298
+ canvasContext.fillRect(trimStartX, 0, trimEndX - trimStartX, height)
299
+ }
300
+ }
301
+
302
+ function drawWaveform() {
303
+ drawBaseWaveform()
304
+ drawOverlays()
305
+ }
306
+
307
+ function handleCanvasMouseDown(event: MouseEvent) {
308
+ if (!interactive || !canvas || !isFinite(duration) || duration <= 0) return
309
+
310
+ const rect = canvas.getBoundingClientRect()
311
+ const x = event.clientX - rect.left
312
+ const clickPosition = x / rect.width
313
+ const clickTime = clickPosition * duration
314
+
315
+ if (showTrimControls && isInTrimMode) {
316
+ const trimStartX = (trimStart / duration) * rect.width
317
+ const trimEndX = (trimEnd / duration) * rect.width
318
+ const threshold = 10
319
+
320
+ if (Math.abs(x - trimStartX) < threshold) {
321
+ isDraggingStart = true
322
+ return
323
+ } else if (Math.abs(x - trimEndX) < threshold) {
324
+ isDraggingEnd = true
325
+ return
326
+ }
327
+ }
328
+
329
+ if (audio) {
330
+ audio.currentTime = clickTime
331
+ currentTime = clickTime
332
+ drawWaveform()
333
+ }
334
+ }
335
+
336
+ function handleCanvasMouseMove(event: MouseEvent) {
337
+ if (!canvas || !isFinite(duration) || duration <= 0) return
338
+
339
+ const rect = canvas.getBoundingClientRect()
340
+ const x = event.clientX - rect.left
341
+ const position = x / rect.width
342
+ const time = Math.max(0, Math.min(duration, position * duration))
343
+
344
+ if (interactive && !isDraggingStart && !isDraggingEnd) {
345
+ const wasHovering = isHovering
346
+ const oldHoverX = hoverX
347
+
348
+ isHovering = true
349
+ hoverX = x
350
+ hoverTime = time
351
+
352
+ if (!wasHovering || Math.abs(oldHoverX - hoverX) > 2) {
353
+ drawBaseWaveform()
354
+ drawOverlays()
355
+ }
356
+ }
357
+
358
+ if (isDraggingStart) {
359
+ trimStart = Math.min(time, trimEnd - 0.1)
360
+ drawWaveform()
361
+ } else if (isDraggingEnd) {
362
+ trimEnd = Math.max(time, trimStart + 0.1)
363
+ drawWaveform()
364
+ }
365
+
366
+ if (showTrimControls && isInTrimMode) {
367
+ const trimStartX = (trimStart / duration) * rect.width
368
+ const trimEndX = (trimEnd / duration) * rect.width
369
+ const threshold = 10
370
+
371
+ if (Math.abs(x - trimStartX) < threshold || Math.abs(x - trimEndX) < threshold) {
372
+ canvas.style.cursor = "ew-resize"
373
+ } else {
374
+ canvas.style.cursor = "pointer"
375
+ }
376
+ } else {
377
+ canvas.style.cursor = "pointer"
378
+ }
379
+ }
380
+
381
+ function handleCanvasMouseUp() {
382
+ isDraggingStart = false
383
+ isDraggingEnd = false
384
+ }
385
+
386
+ function handleCanvasMouseLeave() {
387
+ if (isHovering) {
388
+ isHovering = false
389
+ hoverX = 0
390
+ hoverTime = 0
391
+ drawBaseWaveform()
392
+ drawOverlays()
393
+ }
394
+ }
395
+
396
+ function handlePlay() { isPlaying = true }
397
+ function handlePause() { isPlaying = false }
398
+
399
+ function handleTimeUpdate() {
400
+ if (audio) {
401
+ currentTime = audio.currentTime
402
+ drawWaveform()
403
+ }
404
+ }
405
+
406
+ function handleLoadedMetadata() {
407
+ if (audio && isFinite(audio.duration) && audio.duration > 0) {
408
+ duration = audio.duration
409
+ trimStart = 0
410
+ trimEnd = duration
411
+ isLoading = false
412
+ drawWaveform()
413
+ } else if (providedDuration && providedDuration > 0) {
414
+ duration = providedDuration
415
+ trimStart = 0
416
+ trimEnd = duration
417
+ isLoading = false
418
+ drawWaveform()
419
+ } else {
420
+ duration = 40
421
+ trimStart = 0
422
+ trimEnd = duration
423
+ isLoading = false
424
+ drawWaveform()
425
+ }
426
+ }
427
+
428
+ function handleEnded() {
429
+ isPlaying = false
430
+ currentTime = 0
431
+ drawWaveform()
432
+ }
433
+
434
+ function handleCanPlayThrough() {
435
+ if (audio && isFinite(audio.duration) && audio.duration > 0) {
436
+ duration = audio.duration
437
+ trimStart = 0
438
+ trimEnd = duration
439
+ isLoading = false
440
+ drawWaveform()
441
+ }
442
+ }
443
+
444
+ function handleAudioError() {
445
+ isLoading = false
446
+ }
447
+
448
+ function togglePlayback() {
449
+ if (!audio) return
450
+ if (isPlaying) {
451
+ audio.pause()
452
+ } else {
453
+ audio.play()
454
+ }
455
+ }
456
+
457
+ function formatTime(seconds: number): string {
458
+ if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00"
459
+ const mins = Math.floor(seconds / 60)
460
+ const secs = Math.floor(seconds % 60)
461
+ return `${mins}:${secs.toString().padStart(2, "0")}`
462
+ }
463
+
464
+ function startTrimMode() {
465
+ if (!isFinite(duration) || duration <= 0) return
466
+ isInTrimMode = true
467
+ trimStart = 0
468
+ trimEnd = duration
469
+ drawWaveform()
470
+ }
471
+
472
+ function cancelTrim() {
473
+ isInTrimMode = false
474
+ trimStart = 0
475
+ trimEnd = duration
476
+ drawWaveform()
477
+ }
478
+
479
+ function applyTrim() {
480
+ if (onTrim) onTrim(trimStart, trimEnd)
481
+ isInTrimMode = false
482
+ }
483
+
484
+ function previewTrimSelection() {
485
+ if (audio) {
486
+ audio.currentTime = trimStart
487
+ currentTime = trimStart
488
+ audio.play()
489
+
490
+ const checkTime = () => {
491
+ if (audio.currentTime >= trimEnd) {
492
+ audio.pause()
493
+ } else if (isPlaying) {
494
+ requestAnimationFrame(checkTime)
495
+ }
496
+ }
497
+ requestAnimationFrame(checkTime)
498
+ }
499
+ }
500
+
501
+ $effect(() => {
502
+ if (canvas && canvasContext) {
503
+ const rect = canvas.getBoundingClientRect()
504
+ canvas.width = rect.width
505
+ canvas.height = height
506
+ canvas.style.width = rect.width + "px"
507
+ canvas.style.height = height + "px"
508
+ generateSimpleWaveform()
509
+ }
510
+ })
511
+
512
+ function resizeCanvas() {
513
+ if (!canvas || !canvasContext) return
514
+ const container = canvas.parentElement
515
+ if (!container) return
516
+
517
+ const containerRect = container.getBoundingClientRect()
518
+ let displayWidth = containerRect.width
519
+ const displayHeight = height
520
+
521
+ if (displayWidth <= 0) {
522
+ let parent = container.parentElement
523
+ while (parent && displayWidth <= 0) {
524
+ displayWidth = parent.getBoundingClientRect().width
525
+ parent = parent.parentElement
526
+ }
527
+ }
528
+
529
+ if (displayWidth > 0 && Math.abs(canvas.width - displayWidth) > 2) {
530
+ canvas.width = displayWidth
531
+ canvas.height = displayHeight
532
+ canvas.style.width = displayWidth + "px"
533
+ canvas.style.height = displayHeight + "px"
534
+ waveformData = []
535
+ generateSimpleWaveform()
536
+ }
537
+ }
538
+
539
+ $effect(() => {
540
+ if (!canvas) return
541
+
542
+ const handleResize = () => {
543
+ setTimeout(() => resizeCanvas(), 50)
544
+ }
545
+
546
+ window.addEventListener("resize", handleResize)
547
+
548
+ const observer = new MutationObserver(() => handleResize())
549
+
550
+ if (canvas.parentElement) {
551
+ observer.observe(canvas.parentElement, {
552
+ attributes: true,
553
+ childList: true,
554
+ subtree: true,
555
+ })
556
+ }
557
+
558
+ setTimeout(() => resizeCanvas(), 200)
559
+
560
+ return () => {
561
+ window.removeEventListener("resize", handleResize)
562
+ observer.disconnect()
563
+ }
564
+ })
565
+
566
+ $effect(() => {
567
+ if (audio && audioUrl) {
568
+ currentTime = 0
569
+ duration = 0
570
+ isLoading = true
571
+ audio.load()
572
+ }
573
+ })
574
+
575
+ onDestroy(() => {
576
+ if (audio) audio.pause()
577
+ })
578
+ </script>
579
+
580
+ <div class="audio-waveform w-full">
581
+ <audio
582
+ bind:this={audio}
583
+ src={audioUrl}
584
+ onplay={handlePlay}
585
+ onpause={handlePause}
586
+ ontimeupdate={handleTimeUpdate}
587
+ onloadedmetadata={handleLoadedMetadata}
588
+ oncanplaythrough={handleCanPlayThrough}
589
+ onended={handleEnded}
590
+ onerror={handleAudioError}
591
+ preload="metadata"
592
+ crossorigin="anonymous"
593
+ ></audio>
594
+
595
+ <div class="relative w-full" style="height: {height}px;">
596
+ {#if isLoading}
597
+ <div class="absolute inset-0 flex items-center justify-center bg-base-200 rounded">
598
+ <span class="loading loading-dots loading-sm"></span>
599
+ <span class="ml-2 text-sm">Loading waveform...</span>
600
+ </div>
601
+ {/if}
602
+
603
+ <canvas
604
+ bind:this={canvas}
605
+ class="rounded border border-base-300"
606
+ style="display: block;"
607
+ onmousedown={handleCanvasMouseDown}
608
+ onmousemove={handleCanvasMouseMove}
609
+ onmouseup={handleCanvasMouseUp}
610
+ onmouseleave={handleCanvasMouseLeave}
611
+ ></canvas>
612
+
613
+ {#if isHovering && interactive && hoverX > 0}
614
+ <div
615
+ class="absolute pointer-events-none bg-base-900 text-base-100 text-xs px-2 py-1 rounded shadow-lg z-10"
616
+ style="left: {hoverX - 20}px; top: -30px;"
617
+ >
618
+ {formatTime(hoverTime)}
619
+ </div>
620
+ {/if}
621
+ </div>
622
+
623
+ <div class="flex items-center justify-between mt-3 gap-4">
624
+ <div class="flex items-center gap-3">
625
+ <button
626
+ class="btn btn-circle btn-primary btn-sm"
627
+ onclick={togglePlayback}
628
+ disabled={isLoading}
629
+ aria-label={isPlaying ? "Pause" : "Play"}
630
+ >
631
+ {#if isLoading}
632
+ <span class="loading loading-spinner loading-xs"></span>
633
+ {:else if isPlaying}
634
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
635
+ {:else}
636
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg>
637
+ {/if}
638
+ </button>
639
+
640
+ <div class="text-sm font-mono">
641
+ {formatTime(currentTime)} / {formatTime(duration)}
642
+ </div>
643
+ </div>
644
+
645
+ <div class="flex items-center">
646
+ {#if speedControls}
647
+ {@render speedControls()}
648
+ {/if}
649
+ </div>
650
+ </div>
651
+
652
+ {#if showTrimControls}
653
+ <div class="mt-4">
654
+ {#if !isInTrimMode}
655
+ <button
656
+ class="btn btn-outline btn-sm"
657
+ onclick={startTrimMode}
658
+ disabled={isLoading || !isFinite(duration) || duration <= 0}
659
+ >
660
+ Trim Audio
661
+ </button>
662
+ {:else}
663
+ <div class="space-y-3">
664
+ <div class="text-sm">
665
+ <div class="flex justify-between items-center">
666
+ <span>Selection: {formatTime(trimStart)} - {formatTime(trimEnd)}</span>
667
+ <span class="badge badge-neutral">{formatTime(trimEnd - trimStart)} duration</span>
668
+ </div>
669
+ </div>
670
+
671
+ <div class="flex gap-2">
672
+ <button class="btn btn-outline btn-sm" onclick={previewTrimSelection} disabled={isLoading}>
673
+ Preview
674
+ </button>
675
+ <button class="btn btn-primary btn-sm" onclick={applyTrim} disabled={isLoading || trimEnd <= trimStart}>
676
+ Apply Trim
677
+ </button>
678
+ <button class="btn btn-ghost btn-sm" onclick={cancelTrim}>
679
+ Cancel
680
+ </button>
681
+ </div>
682
+
683
+ <div class="text-xs text-base-content/60">
684
+ Drag the red lines to select the audio section you want to keep
685
+ </div>
686
+ </div>
687
+ {/if}
688
+ </div>
689
+ {/if}
690
+ </div>
691
+
692
+ <style>
693
+ .audio-waveform canvas { display: block; }
694
+ </style>