wally-ui 1.14.1 → 1.16.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/dist/cli.js +0 -0
- package/package.json +1 -1
- package/playground/showcase/public/sitemap.xml +15 -0
- package/playground/showcase/src/app/app.routes.server.ts +8 -0
- package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.html +11 -2
- package/playground/showcase/src/app/components/ai/ai-composer/ai-composer.ts +13 -3
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.css +0 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.html +41 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.spec.ts +16 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.service.ts +175 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.spec.ts +23 -0
- package/playground/showcase/src/app/components/audio-waveform/audio-waveform.ts +64 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.html +41 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.spec.ts +228 -0
- package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.ts +217 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.html +3 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.spec.ts +56 -0
- package/playground/showcase/src/app/components/combobox/combobox-empty/combobox-empty.ts +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.html +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.spec.ts +57 -0
- package/playground/showcase/src/app/components/combobox/combobox-group/combobox-group.ts +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.html +71 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.spec.ts +468 -0
- package/playground/showcase/src/app/components/combobox/combobox-input/combobox-input.ts +90 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.html +58 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.spec.ts +173 -0
- package/playground/showcase/src/app/components/combobox/combobox-item/combobox-item.ts +37 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.html +11 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.spec.ts +166 -0
- package/playground/showcase/src/app/components/combobox/combobox-search/combobox-search.ts +36 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.html +8 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.spec.ts +137 -0
- package/playground/showcase/src/app/components/combobox/combobox-trigger/combobox-trigger.ts +30 -0
- package/playground/showcase/src/app/components/combobox/combobox.css +0 -0
- package/playground/showcase/src/app/components/combobox/combobox.html +3 -0
- package/playground/showcase/src/app/components/combobox/combobox.spec.ts +391 -0
- package/playground/showcase/src/app/components/combobox/combobox.ts +59 -0
- package/playground/showcase/src/app/components/combobox/lib/models/combobox.model.ts +13 -0
- package/playground/showcase/src/app/components/combobox/lib/service/combobox.service.spec.ts +530 -0
- package/playground/showcase/src/app/components/combobox/lib/service/combobox.service.ts +191 -0
- package/playground/showcase/src/app/components/combobox/lib/types/combobox-position.type.ts +1 -0
- package/playground/showcase/src/app/components/combobox/lib/types/combobox-trigger-mode.type.ts +1 -0
- package/playground/showcase/src/app/core/services/seo.service.ts +100 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.css +1 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.examples.ts +146 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.html +576 -0
- package/playground/showcase/src/app/pages/documentation/components/audio-waveform-docs/audio-waveform-docs.ts +124 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.css +0 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.html +383 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.spec.ts +23 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.component.ts +333 -0
- package/playground/showcase/src/app/pages/documentation/components/combobox-docs/combobox-docs.examples.ts +226 -0
- package/playground/showcase/src/app/pages/documentation/components/components.html +27 -0
- package/playground/showcase/src/app/pages/documentation/components/components.routes.ts +8 -0
- package/playground/showcase/src/app/pages/home/home.html +1 -1
package/dist/cli.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -68,6 +68,21 @@
|
|
|
68
68
|
<priority>0.8</priority>
|
|
69
69
|
</url>
|
|
70
70
|
|
|
71
|
+
<url>
|
|
72
|
+
<loc>https://wally-ui.com/documentation/components/selection-popover</loc>
|
|
73
|
+
<lastmod>2025-10-18</lastmod>
|
|
74
|
+
<changefreq>monthly</changefreq>
|
|
75
|
+
<priority>0.8</priority>
|
|
76
|
+
</url>
|
|
77
|
+
|
|
78
|
+
<!-- Audio Waveform Documentation -->
|
|
79
|
+
<url>
|
|
80
|
+
<loc>https://wally-ui.com/documentation/components/audio-waveform</loc>
|
|
81
|
+
<lastmod>2025-10-18</lastmod>
|
|
82
|
+
<changefreq>monthly</changefreq>
|
|
83
|
+
<priority>0.8</priority>
|
|
84
|
+
</url>
|
|
85
|
+
|
|
71
86
|
<!-- Chat SDK Documentation -->
|
|
72
87
|
<url>
|
|
73
88
|
<loc>https://wally-ui.com/documentation/chat-sdk</loc>
|
|
@@ -37,6 +37,14 @@ export const serverRoutes: ServerRoute[] = [
|
|
|
37
37
|
path: 'documentation/components/selection-popover',
|
|
38
38
|
renderMode: RenderMode.Prerender,
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
path: 'documentation/components/audio-waveform',
|
|
42
|
+
renderMode: RenderMode.Prerender,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
path: 'documentation/components/combobox',
|
|
46
|
+
renderMode: RenderMode.Prerender,
|
|
47
|
+
},
|
|
40
48
|
{
|
|
41
49
|
path: 'documentation/chat-sdk',
|
|
42
50
|
renderMode: RenderMode.Prerender,
|
|
@@ -35,10 +35,18 @@
|
|
|
35
35
|
</div>
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
<div class="w-full px-4 pt-4"
|
|
38
|
+
<div class="w-full px-4 pt-4 transition-all duration-1000 ease-in-out overflow-hidden" [class.opacity-0]="isStartRecoding()" [class.opacity-100]="!isStartRecoding()"
|
|
39
|
+
[class.max-h-0]="isStartRecoding()" [class.max-h-96]="!isStartRecoding()">
|
|
39
40
|
<wally-ai-prompt-input></wally-ai-prompt-input>
|
|
40
41
|
</div>
|
|
41
42
|
|
|
43
|
+
<div class="w-full transition-all duration-700 ease-in-out overflow-hidden" [class.opacity-0]="!isStartRecoding()"
|
|
44
|
+
[class.opacity-100]="isStartRecoding()" [class.scale-95]="!isStartRecoding()"
|
|
45
|
+
[class.scale-100]="isStartRecoding()" [class.max-h-0]="!isStartRecoding()" [class.max-h-32]="isStartRecoding()">
|
|
46
|
+
<wally-audio-waveform [isStartRecording]="isStartRecoding()" [isStopRecording]="isStopRecoding()">
|
|
47
|
+
</wally-audio-waveform>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
42
50
|
<!-- Action Buttons -->
|
|
43
51
|
<div class="w-full flex items-center justify-between p-1" role="toolbar" aria-label="Composer actions">
|
|
44
52
|
<div class="w-full flex items-center gap-1">
|
|
@@ -186,7 +194,8 @@
|
|
|
186
194
|
<div class="w-full size-10 flex gap-2 justify-end">
|
|
187
195
|
<div>
|
|
188
196
|
<wally-tooltip [text]="'Use voice input'" position="bottom">
|
|
189
|
-
<wally-button [variant]="'ghost'" [rounded]="true"
|
|
197
|
+
<wally-button (buttonClick)="toggleInputVoice()" [variant]="'ghost'" [rounded]="true"
|
|
198
|
+
[ariaLabel]="'Send message'">
|
|
190
199
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
|
191
200
|
stroke="currentColor" class="size-6">
|
|
192
201
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Component, input } from '@angular/core';
|
|
1
|
+
import { Component, input, InputSignal, signal, WritableSignal } from '@angular/core';
|
|
2
2
|
|
|
3
3
|
import { DropdownMenuSubTrigger } from '../../dropdown-menu/dropdown-menu-sub-trigger/dropdown-menu-sub-trigger';
|
|
4
4
|
import { DropdownMenuSubContent } from '../../dropdown-menu/dropdown-menu-sub-content/dropdown-menu-sub-content';
|
|
@@ -9,6 +9,7 @@ import { DropdownMenuLabel } from '../../dropdown-menu/dropdown-menu-label/dropd
|
|
|
9
9
|
import { DropdownMenuGroup } from '../../dropdown-menu/dropdown-menu-group/dropdown-menu-group';
|
|
10
10
|
import { DropdownMenuItem } from '../../dropdown-menu/dropdown-menu-item/dropdown-menu-item';
|
|
11
11
|
import { DropdownMenuSub } from '../../dropdown-menu/dropdown-menu-sub/dropdown-menu-sub';
|
|
12
|
+
import { AudioWaveform } from '../../audio-waveform/audio-waveform';
|
|
12
13
|
import { AiPromptInput } from '../ai-prompt-input/ai-prompt-input';
|
|
13
14
|
import { DropdownMenu } from '../../dropdown-menu/dropdown-menu';
|
|
14
15
|
import { Tooltip } from '../../tooltip/tooltip';
|
|
@@ -29,15 +30,24 @@ import { Button } from '../../button/button';
|
|
|
29
30
|
DropdownMenuGroup,
|
|
30
31
|
DropdownMenuSub,
|
|
31
32
|
DropdownMenuSubTrigger,
|
|
32
|
-
DropdownMenuSubContent
|
|
33
|
+
DropdownMenuSubContent,
|
|
34
|
+
AudioWaveform
|
|
33
35
|
],
|
|
34
36
|
templateUrl: './ai-composer.html',
|
|
35
37
|
styleUrl: './ai-composer.css'
|
|
36
38
|
})
|
|
37
39
|
export class AiComposer {
|
|
38
|
-
textSelected = input<string>('');
|
|
40
|
+
textSelected: InputSignal<string> = input<string>('');
|
|
41
|
+
|
|
42
|
+
isStartRecoding: WritableSignal<boolean> = signal<boolean>(false);
|
|
43
|
+
isStopRecoding: WritableSignal<boolean> = signal<boolean>(true);
|
|
39
44
|
|
|
40
45
|
onItemClick(): void {
|
|
41
46
|
console.log('Item clicked');
|
|
42
47
|
}
|
|
48
|
+
|
|
49
|
+
toggleInputVoice(): void {
|
|
50
|
+
this.isStartRecoding.set(!this.isStartRecoding());
|
|
51
|
+
this.isStopRecoding.set(!this.isStartRecoding());
|
|
52
|
+
}
|
|
43
53
|
}
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<div class="w-full flex flex-col items-center gap-4 p-3">
|
|
2
|
+
|
|
3
|
+
<!-- Waveform Bars Container -->
|
|
4
|
+
@if (isStartRecording()) {
|
|
5
|
+
<div class="flex items-center justify-center gap-1 h-11 w-full">
|
|
6
|
+
@if (audioWaveformService.isRecording()) {
|
|
7
|
+
<!-- Active Recording: Animated Bars -->
|
|
8
|
+
@for (barHeight of audioWaveformService.audioData(); track $index) {
|
|
9
|
+
<div class="flex-1 min-w-[2px] max-w-[7px] bg-[#0a0a0a] dark:bg-white rounded-full transition-all duration-200 ease-out"
|
|
10
|
+
[style.height.%]="barHeight || 5" [style.min-height.px]="4">
|
|
11
|
+
</div>
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
<!-- Recording Timer -->
|
|
19
|
+
@if (showTimer() && audioWaveformService.isRecording()) {
|
|
20
|
+
<div class="text-sm text-[#0a0a0a] dark:text-white">
|
|
21
|
+
{{ formattedTime }}
|
|
22
|
+
</div>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
<!-- Controls -->
|
|
26
|
+
<!-- <div class="flex gap-2">
|
|
27
|
+
@if (!audioWaveformService.isRecording()) {
|
|
28
|
+
<button (click)="startRecording()"
|
|
29
|
+
class="px-4 py-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
|
30
|
+
aria-label="Start recording">
|
|
31
|
+
Start Recording
|
|
32
|
+
</button>
|
|
33
|
+
} @else {
|
|
34
|
+
<button (click)="stopRecording()"
|
|
35
|
+
class="px-4 py-2 bg-neutral-500 text-white rounded-full hover:bg-neutral-600 transition-colors"
|
|
36
|
+
aria-label="Stop recording">
|
|
37
|
+
Stop Recording
|
|
38
|
+
</button>
|
|
39
|
+
}
|
|
40
|
+
</div> -->
|
|
41
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { AudioWaveformService } from './audio-waveform.service';
|
|
4
|
+
|
|
5
|
+
describe('AudioWaveformService', () => {
|
|
6
|
+
let service: AudioWaveformService;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
TestBed.configureTestingModule({});
|
|
10
|
+
service = TestBed.inject(AudioWaveformService);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should be created', () => {
|
|
14
|
+
expect(service).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Injectable, signal, WritableSignal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class AudioWaveformService {
|
|
5
|
+
isRecording: WritableSignal<boolean> = signal<boolean>(false);
|
|
6
|
+
audioData: WritableSignal<number[]> = signal<number[]>([]); // Frequency values for bars
|
|
7
|
+
|
|
8
|
+
// Recorded audio signals
|
|
9
|
+
recordedAudioBlob: WritableSignal<Blob | null> = signal<Blob | null>(null);
|
|
10
|
+
recordedAudioUrl: WritableSignal<string | null> = signal<string | null>(null);
|
|
11
|
+
|
|
12
|
+
// Audio API references
|
|
13
|
+
private audioContext: AudioContext | null = null;
|
|
14
|
+
private analyser: AnalyserNode | null = null;
|
|
15
|
+
private microphone: MediaStreamAudioSourceNode | null = null;
|
|
16
|
+
private animationFrame: number | null = null;
|
|
17
|
+
private stream: MediaStream | null = null;
|
|
18
|
+
|
|
19
|
+
// Configuration
|
|
20
|
+
private readonly FFT_SIZE = 256; // Higher = more frequency detail
|
|
21
|
+
private readonly SMOOTHING = 0.8; // 0 (no smoothing) to 1 (max smoothing)
|
|
22
|
+
|
|
23
|
+
// Responsive bar count based on screen width
|
|
24
|
+
private get BAR_COUNT(): number {
|
|
25
|
+
if (typeof window === 'undefined') return 30; // SSR fallback
|
|
26
|
+
return window.innerWidth < 768 ? 30 : 65; // Mobile: 30 bars, Desktop: 65 bars
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// MediaRecorded
|
|
30
|
+
private mediaRecorder: MediaRecorder | null = null;
|
|
31
|
+
private audioChunks: Blob[] = [];
|
|
32
|
+
|
|
33
|
+
async startRecording(): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
// Request microphone access
|
|
36
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
37
|
+
audio: {
|
|
38
|
+
echoCancellation: true,
|
|
39
|
+
noiseSuppression: true,
|
|
40
|
+
autoGainControl: true
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Create audio context
|
|
45
|
+
this.audioContext = new AudioContext();
|
|
46
|
+
|
|
47
|
+
// Create analyzer
|
|
48
|
+
this.analyser = this.audioContext.createAnalyser();
|
|
49
|
+
this.analyser.fftSize = this.FFT_SIZE;
|
|
50
|
+
this.analyser.smoothingTimeConstant = this.SMOOTHING;
|
|
51
|
+
|
|
52
|
+
// Connect microphone to analyzer
|
|
53
|
+
this.microphone = this.audioContext.createMediaStreamSource(this.stream);
|
|
54
|
+
this.microphone?.connect(this.analyser);
|
|
55
|
+
|
|
56
|
+
// Init MediaRecorded
|
|
57
|
+
this.audioChunks = [];
|
|
58
|
+
this.mediaRecorder = new MediaRecorder(this.stream);
|
|
59
|
+
|
|
60
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
61
|
+
if (event.data.size > 0) {
|
|
62
|
+
this.audioChunks.push(event.data);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
this.mediaRecorder.onstop = () => {
|
|
67
|
+
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
|
|
68
|
+
this.recordedAudioBlob.set(audioBlob);
|
|
69
|
+
|
|
70
|
+
const audioUrl = URL.createObjectURL(audioBlob);
|
|
71
|
+
this.recordedAudioUrl.set(audioUrl);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
this.mediaRecorder.start();
|
|
75
|
+
|
|
76
|
+
// Start animation loop
|
|
77
|
+
this.isRecording.set(true);
|
|
78
|
+
this.updateWaveForm();
|
|
79
|
+
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Microphone access denied:', error);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stopRecording(): void {
|
|
87
|
+
// Stop animation
|
|
88
|
+
if (this.animationFrame) {
|
|
89
|
+
cancelAnimationFrame(this.animationFrame);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
|
93
|
+
this.mediaRecorder.stop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Disconnect audio nodes
|
|
97
|
+
this.microphone?.disconnect();
|
|
98
|
+
this.audioContext?.close();
|
|
99
|
+
|
|
100
|
+
// Stop microphone strem
|
|
101
|
+
this.stream?.getTracks().forEach(track => track.stop());
|
|
102
|
+
|
|
103
|
+
// Reset state
|
|
104
|
+
this.isRecording.set(false);
|
|
105
|
+
this.audioData.set([]);
|
|
106
|
+
|
|
107
|
+
// Clean up references
|
|
108
|
+
this.audioContext = null;
|
|
109
|
+
this.analyser = null;
|
|
110
|
+
this.microphone = null;
|
|
111
|
+
this.stream = null;
|
|
112
|
+
this.mediaRecorder = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Download recorded audio
|
|
116
|
+
downloadRecording(filename: string = `recording-${Date.now()}.webm`): void {
|
|
117
|
+
const url = this.recordedAudioUrl();
|
|
118
|
+
|
|
119
|
+
if (!url) {
|
|
120
|
+
console.error('No recorded audio available');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const a = document.createElement('a');
|
|
125
|
+
a.href = url;
|
|
126
|
+
a.download = filename;
|
|
127
|
+
a.click();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Clear recorded audio and free memory
|
|
131
|
+
clearRecording(): void {
|
|
132
|
+
// Revoke URL to free memory
|
|
133
|
+
const url = this.recordedAudioUrl();
|
|
134
|
+
if (url) {
|
|
135
|
+
URL.revokeObjectURL(url);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.recordedAudioBlob.set(null);
|
|
139
|
+
this.recordedAudioUrl.set(null);
|
|
140
|
+
this.audioChunks = [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private updateWaveForm(): void {
|
|
144
|
+
if (!this.analyser || !this.isRecording()) return;
|
|
145
|
+
|
|
146
|
+
// Get frequency data
|
|
147
|
+
const bufferLength: number = this.analyser.frequencyBinCount; // 128 (FFT_SIZE/2)
|
|
148
|
+
const dataArray: Uint8Array<ArrayBuffer> = new Uint8Array(bufferLength);
|
|
149
|
+
this.analyser.getByteFrequencyData(dataArray); // Values 0-255
|
|
150
|
+
|
|
151
|
+
// Reduce to BAR_COUNT bars (average frequencies)
|
|
152
|
+
const bars: number[] = [];
|
|
153
|
+
const samplesPerBar: number = Math.floor(bufferLength / this.BAR_COUNT);
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < this.BAR_COUNT; i++) {
|
|
156
|
+
const start = i * samplesPerBar;
|
|
157
|
+
const end = start + samplesPerBar;
|
|
158
|
+
|
|
159
|
+
// Average frequency values for this bar
|
|
160
|
+
let sum = 0;
|
|
161
|
+
for (let j = start; j < end; j++) {
|
|
162
|
+
sum += dataArray[j];
|
|
163
|
+
}
|
|
164
|
+
const average = sum / samplesPerBar;
|
|
165
|
+
|
|
166
|
+
// Normalize to 0-100 range for height percentage
|
|
167
|
+
const normalized = (average / 255) * 100;
|
|
168
|
+
bars.push(normalized);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.audioData.set(bars);
|
|
172
|
+
|
|
173
|
+
this.animationFrame = requestAnimationFrame(() => this.updateWaveForm());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { AudioWaveform } from './audio-waveform';
|
|
4
|
+
|
|
5
|
+
describe('AudioWaveform', () => {
|
|
6
|
+
let component: AudioWaveform;
|
|
7
|
+
let fixture: ComponentFixture<AudioWaveform>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [AudioWaveform]
|
|
12
|
+
})
|
|
13
|
+
.compileComponents();
|
|
14
|
+
|
|
15
|
+
fixture = TestBed.createComponent(AudioWaveform);
|
|
16
|
+
component = fixture.componentInstance;
|
|
17
|
+
fixture.detectChanges();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should create', () => {
|
|
21
|
+
expect(component).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Component, effect, input, InputSignal, signal, WritableSignal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
import { AudioWaveformService } from './audio-waveform.service';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'wally-audio-waveform',
|
|
7
|
+
imports: [],
|
|
8
|
+
providers: [AudioWaveformService],
|
|
9
|
+
templateUrl: './audio-waveform.html',
|
|
10
|
+
styleUrl: './audio-waveform.css'
|
|
11
|
+
})
|
|
12
|
+
export class AudioWaveform {
|
|
13
|
+
isStartRecording: InputSignal<boolean> = input<boolean>(false);
|
|
14
|
+
isStopRecording: InputSignal<boolean> = input<boolean>(false);
|
|
15
|
+
showTimer: InputSignal<boolean> = input<boolean>(false);
|
|
16
|
+
|
|
17
|
+
recordingTime: WritableSignal<number> = signal<number>(0);
|
|
18
|
+
private timerInterval: any = null;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
public audioWaveformService: AudioWaveformService
|
|
22
|
+
) {
|
|
23
|
+
effect(() => {
|
|
24
|
+
if (this.isStartRecording()) {
|
|
25
|
+
this.startRecording();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
effect(() => {
|
|
29
|
+
if (this.isStopRecording()) {
|
|
30
|
+
this.stopRecording();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async startRecording(): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await this.audioWaveformService.startRecording();
|
|
38
|
+
|
|
39
|
+
this.recordingTime.set(0);
|
|
40
|
+
this.timerInterval = setInterval(() => {
|
|
41
|
+
this.recordingTime.update(time => time + 1);
|
|
42
|
+
}, 1000);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error('Failed to start recording:', error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
stopRecording(): void {
|
|
49
|
+
this.audioWaveformService.stopRecording();
|
|
50
|
+
|
|
51
|
+
// Stop timer
|
|
52
|
+
if (this.timerInterval) {
|
|
53
|
+
clearInterval(this.timerInterval);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Format seconds to MM:SS
|
|
58
|
+
get formattedTime(): string {
|
|
59
|
+
const time = this.recordingTime();
|
|
60
|
+
const minutes = Math.floor(time / 60);
|
|
61
|
+
const seconds = time % 60;
|
|
62
|
+
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
File without changes
|
package/playground/showcase/src/app/components/combobox/combobox-content/combobox-content.html
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@if (comboboxService.isOpen()) {
|
|
2
|
+
<div
|
|
3
|
+
[class]="'absolute bg-white dark:bg-[#1b1b1b] dark:border-neutral-700 rounded-xl shadow-2xl border border-neutral-300 w-full z-50 transition-all duration-200 ease-out ' + positionClasses()"
|
|
4
|
+
id="combobox-listbox"
|
|
5
|
+
role="listbox"
|
|
6
|
+
[attr.aria-multiselectable]="comboboxService.multiSelect()"
|
|
7
|
+
>
|
|
8
|
+
<!-- Search (só no custom trigger mode) -->
|
|
9
|
+
@if (comboboxService.triggerMode() === 'custom') {
|
|
10
|
+
<wally-combobox-search></wally-combobox-search>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
<div class="p-1">
|
|
14
|
+
@if (comboboxService.filteredData().length === 0) {
|
|
15
|
+
<wally-combobox-empty></wally-combobox-empty>
|
|
16
|
+
} @else if (comboboxService.groupedData(); as groups) {
|
|
17
|
+
<!-- Itens agrupados -->
|
|
18
|
+
@for (group of groups; track group.label; let groupIdx = $index) {
|
|
19
|
+
<wally-combobox-group [label]="group.label">
|
|
20
|
+
@for (item of group.items; track item.value; let itemIdx = $index) {
|
|
21
|
+
<wally-combobox-item
|
|
22
|
+
[item]="item"
|
|
23
|
+
[focused]="comboboxService.focusedIndex() === getGlobalIndex(groupIdx, itemIdx)"
|
|
24
|
+
[selected]="comboboxService.isSelected(item.value)"
|
|
25
|
+
></wally-combobox-item>
|
|
26
|
+
}
|
|
27
|
+
</wally-combobox-group>
|
|
28
|
+
}
|
|
29
|
+
} @else {
|
|
30
|
+
<!-- Itens normais -->
|
|
31
|
+
@for (item of comboboxService.filteredData(); track item.value; let idx = $index) {
|
|
32
|
+
<wally-combobox-item
|
|
33
|
+
[item]="item"
|
|
34
|
+
[focused]="comboboxService.focusedIndex() === idx"
|
|
35
|
+
[selected]="comboboxService.isSelected(item.value)"
|
|
36
|
+
></wally-combobox-item>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
}
|